A complete reference for building type-safe, error-handled Nuxt applications using
typescript-result
- Core Principles
- Installation & Setup
- Client-Side Patterns
- Server-Side Patterns
- State Management
- API Integration
- Error Handling
- Best Practices
- Common Patterns
- Anti-Patterns
- ✅ Type-safe error handling - Errors are tracked in the type system
- ✅ No try-catch needed - Library handles error catching
- ✅ Explicit failure paths - Forces you to handle errors
- ✅ Composable - Chain operations elegantly
- ✅ Pattern matching - Match on specific error types
- ✅ Zero dependencies - Only 2 KB minified + gzipped
// Result.ok() - Create success result
const success = Result.ok(42)
// Result.error() - Create error result
const failure = Result.error(new Error('Something went wrong'))
// Result.wrap() - Wrap a function that might throw (returns a reusable function)
const safeFetch = Result.wrap(
(url: string) => fetch(url),
(error) => new ApiError('Fetch failed', error)
)
// Result.try() - Execute a function and catch errors (executes immediately)
const result = Result.try(
() => JSON.parse(data),
(error) => new ParseError('Invalid JSON', error)
)
// Result.gen() - Generator-based control flow (like async/await for Result)
const result = await Result.gen(async function* () {
const user = yield* await fetchUser(id)
const posts = yield* await fetchPosts(user.id)
return { user, posts }
})- Never wrap Result.gen in try-catch - The library already handles errors
- Use
yield*in generators - Automatically unwraps Result values - Use Result.wrap for reusable functions - Creates a wrapper you can call multiple times
- Use Result.try for one-off operations - Inside generators or standalone
- Always handle both ok and error cases - Use
.match(),.mapError(), or check.ok
npm install typescript-result// tsconfig.json
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true
}
}// types/errors.ts
export class ApiError extends Error {
readonly type = 'api-error'
constructor(message: string, public cause?: unknown) {
super(message)
}
}
export class ValidationError extends Error {
readonly type = 'validation-error'
constructor(message: string, public fields?: Record<string, string[]>) {
super(message)
}
}
export class NetworkError extends Error {
readonly type = 'network-error'
constructor(message: string) {
super(message)
}
}// plugins/api.ts
import { Result } from 'typescript-result'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const api = $fetch.create({
baseURL: config.public.apiBase,
onRequest({ options }) {
const token = useCookie('auth-token')
if (token.value) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token.value}`
}
}
},
async onResponseError({ response }) {
if (response.status === 401) {
await nuxtApp.runWithContext(() => navigateTo('/login'))
}
}
})
return {
provide: {
api
}
}
})// composables/useAPI.ts
import type { UseFetchOptions } from 'nuxt/app'
// Only use this in components, NOT in Pinia stores
export function useAPI<T>(
url: string | (() => string),
options?: UseFetchOptions<T>
) {
return useFetch(url, {
...options,
$fetch: useNuxtApp().$api as typeof $fetch
})
}// composables/useEntityAdapter.ts
import { Result } from 'typescript-result'
export interface EntityAdapter<T extends { id: string }> {
entities: ReadonlyMap<string, T>
ids: ComputedRef<string[]>
all: ComputedRef<T[]>
selectById: (id: string) => ComputedRef<T | undefined>
selectMany: (ids: string[]) => ComputedRef<T[]>
addOne: (entity: T) => Result<T, Error>
addMany: (items: T[]) => Result<T[], Error>
updateOne: (id: string, changes: Partial<T>) => Result<T, Error>
upsertOne: (entity: T) => Result<T, Error>
upsertMany: (items: T[]) => Result<T[], Error>
removeOne: (id: string) => Result<void, Error>
removeMany: (ids: string[]) => Result<void, Error>
setAll: (items: T[]) => Result<T[], Error>
reset: () => void
}
export function createEntityAdapter<T extends { id: string }>(): EntityAdapter<T> {
const entities = reactive(new Map<string, T>())
const ids = computed(() => Array.from(entities.keys()))
const all = computed(() => Array.from(entities.values()))
function selectById(id: string) {
return computed(() => entities.get(id))
}
function selectMany(ids: string[]) {
return computed(() =>
ids.map(id => entities.get(id)).filter((e): e is T => !!e)
)
}
function addOne(entity: T): Result<T, Error> {
if (entities.has(entity.id)) {
return Result.error(new Error(`Entity ${entity.id} already exists`))
}
entities.set(entity.id, entity)
return Result.ok(entity)
}
function addMany(items: T[]): Result<T[], Error> {
const conflicts = items.filter(item => entities.has(item.id))
if (conflicts.length > 0) {
return Result.error(
new Error(`Entities already exist: ${conflicts.map(c => c.id).join(', ')}`)
)
}
items.forEach(item => entities.set(item.id, item))
return Result.ok(items)
}
function updateOne(id: string, changes: Partial<T>): Result<T, Error> {
const entity = entities.get(id)
if (!entity) {
return Result.error(new Error(`Entity ${id} not found`))
}
const updated = { ...entity, ...changes }
entities.set(id, updated)
return Result.ok(updated)
}
function upsertOne(entity: T): Result<T, Error> {
entities.set(entity.id, entity)
return Result.ok(entity)
}
function upsertMany(items: T[]): Result<T[], Error> {
items.forEach(item => entities.set(item.id, item))
return Result.ok(items)
}
function removeOne(id: string): Result<void, Error> {
if (!entities.has(id)) {
return Result.error(new Error(`Entity ${id} not found`))
}
entities.delete(id)
return Result.ok(undefined)
}
function removeMany(ids: string[]): Result<void, Error> {
const missing = ids.filter(id => !entities.has(id))
if (missing.length > 0) {
return Result.error(new Error(`Entities not found: ${missing.join(', ')}`))
}
ids.forEach(id => entities.delete(id))
return Result.ok(undefined)
}
function setAll(items: T[]): Result<T[], Error> {
entities.clear()
items.forEach(item => entities.set(item.id, item))
return Result.ok(items)
}
function reset() {
entities.clear()
}
return {
entities: readonly(entities) as ReadonlyMap<string, T>,
ids,
all,
selectById,
selectMany,
addOne,
addMany,
updateOne,
upsertOne,
upsertMany,
removeOne,
removeMany,
setAll,
reset
}
}// composables/useAsyncState.ts
import { Result } from 'typescript-result'
export interface AsyncState<E = Error> {
loading: boolean
error: E | null
lastFetch: number | null
retryCount: number
}
export function createAsyncState<E = Error>(): AsyncState<E> {
return {
loading: false,
error: null,
lastFetch: null,
retryCount: 0
}
}
export interface WithAsyncStateOptions<T, E> {
onSuccess?: (data: T) => void | Promise<void>
onError?: (error: E) => void | Promise<void>
onFinally?: () => void | Promise<void>
resetRetryOnSuccess?: boolean
optimistic?: {
apply: () => void
rollback: () => void
}
}
export async function withAsyncState<T, E = Error>(
state: AsyncState<E>,
operation: () => Promise<T>,
options?: WithAsyncStateOptions<T, E>
): Promise<Result<T, E>> {
const {
onSuccess,
onError,
onFinally,
resetRetryOnSuccess = true,
optimistic
} = options ?? {}
state.loading = true
state.error = null
optimistic?.apply()
const result = await Result.gen(async function* () {
const data = yield* await Result.wrap(
operation,
(error) => error as E
)()
state.loading = false
state.lastFetch = Date.now()
if (resetRetryOnSuccess) {
state.retryCount = 0
}
await onSuccess?.(data)
return data
}).mapError(async (error) => {
state.loading = false
state.error = error
state.retryCount++
optimistic?.rollback()
await onError?.(error)
return error
})
await onFinally?.()
return result
}
export function isStale(state: AsyncState, staleTime = 5 * 60 * 1000): boolean {
if (!state.lastFetch) return true
return Date.now() - state.lastFetch > staleTime
}
export function resetAsyncState<E = Error>(state: AsyncState<E>): void {
Object.assign(state, createAsyncState<E>())
}stores/
products/
index.ts # Store assembly
state.ts # State types and creation
actions.ts # Actions with Result
getters.ts # Computed getters
// stores/products/state.ts
import type { AsyncState } from '~/composables/useAsyncState'
export interface ProductState {
fetch: AsyncState<ApiError>
create: AsyncState<ApiError>
update: Map<string, AsyncState<ApiError>>
delete: Map<string, AsyncState<ApiError>>
}
export function createProductState(): ProductState {
return {
fetch: createAsyncState<ApiError>(),
create: createAsyncState<ApiError>(),
update: new Map(),
delete: new Map()
}
}// stores/products/actions.ts
import { Result } from 'typescript-result'
import type { EntityAdapter } from '~/composables/useEntityAdapter'
import type { ProductState } from './state'
interface CreateProductData {
name: string
price: number
description?: string
}
interface UpdateProductData {
name?: string
price?: number
description?: string
}
export function createProductActions(
adapter: EntityAdapter<Product>,
state: ProductState
) {
const { $api } = useNuxtApp()
// Wrap API calls - reusable functions
const fetchProductsApi = Result.wrap(
() => $api<Product[]>('/products'),
(error) => new ApiError('Failed to fetch products', error)
)
const createProductApi = Result.wrap(
(data: CreateProductData) => $api<Product>('/products', {
method: 'POST',
body: data
}),
(error) => new ApiError('Failed to create product', error)
)
const updateProductApi = Result.wrap(
(id: string, data: UpdateProductData) => $api<Product>(`/products/${id}`, {
method: 'PATCH',
body: data
}),
(error) => new ApiError('Failed to update product', error)
)
const deleteProductApi = Result.wrap(
(id: string) => $api(`/products/${id}`, { method: 'DELETE' }),
(error) => new ApiError('Failed to delete product', error)
)
// Fetch all products
async function fetchAll(options?: {
force?: boolean
staleTime?: number
}): Promise<Result<Product[], ApiError>> {
const { force = false, staleTime = 5 * 60 * 1000 } = options ?? {}
if (!force && !isStale(state.fetch, staleTime)) {
return Result.ok(adapter.all.value)
}
return withAsyncState(
state.fetch,
async () => {
const products = await fetchProductsApi()
adapter.setAll(products).unwrap()
return products
}
)
}
// Create product
async function create(data: CreateProductData): Promise<Result<Product, ApiError>> {
return withAsyncState(
state.create,
async () => {
const created = await createProductApi(data)
adapter.addOne(created).unwrap()
return created
},
{
onSuccess: () => {
state.fetch.lastFetch = null
}
}
)
}
// Update with optimistic update
async function update(
id: string,
data: UpdateProductData
): Promise<Result<Product, ApiError>> {
const existing = adapter.selectById(id).value
if (!existing) {
return Result.error(new ApiError('Product not found'))
}
if (!state.update.has(id)) {
state.update.set(id, createAsyncState<ApiError>())
}
return withAsyncState(
state.update.get(id)!,
async () => {
const updated = await updateProductApi(id, data)
adapter.upsertOne(updated).unwrap()
return updated
},
{
optimistic: {
apply: () => {
adapter.updateOne(id, data)
},
rollback: () => {
adapter.upsertOne(existing)
}
},
onSuccess: () => {
state.fetch.lastFetch = null
}
}
)
}
// Delete with optimistic delete
async function remove(id: string): Promise<Result<void, ApiError>> {
const existing = adapter.selectById(id).value
if (!existing) {
return Result.error(new ApiError('Product not found'))
}
if (!state.delete.has(id)) {
state.delete.set(id, createAsyncState<ApiError>())
}
return withAsyncState(
state.delete.get(id)!,
async () => {
await deleteProductApi(id)
},
{
optimistic: {
apply: () => {
adapter.removeOne(id)
},
rollback: () => {
adapter.upsertOne(existing)
}
},
onSuccess: () => {
state.fetch.lastFetch = null
state.delete.delete(id)
state.update.delete(id)
}
}
)
}
function invalidate() {
state.fetch.lastFetch = null
}
function reset() {
adapter.reset()
Object.assign(state, createProductState())
}
return {
fetchAll,
create,
update,
remove,
invalidate,
reset
}
}// stores/products/getters.ts
import type { EntityAdapter } from '~/composables/useEntityAdapter'
import type { ProductState } from './state'
export function createProductGetters(
adapter: EntityAdapter<Product>,
state: ProductState
) {
const all = adapter.all
const ids = adapter.ids
const byId = (id: string) => adapter.selectById(id)
const sorted = computed(() =>
adapter.all.value.sort((a, b) => a.name.localeCompare(b.name))
)
const isLoading = computed(() => state.fetch.loading)
const isCreating = computed(() => state.create.loading)
const isUpdating = (id: string) => computed(() => state.update.get(id)?.loading ?? false)
const isDeleting = (id: string) => computed(() => state.delete.get(id)?.loading ?? false)
const error = computed(() => state.fetch.error)
const createError = computed(() => state.create.error)
const updateError = (id: string) => computed(() => state.update.get(id)?.error ?? null)
const deleteError = (id: string) => computed(() => state.delete.get(id)?.error ?? null)
const isFresh = computed(() => !isStale(state.fetch))
const count = computed(() => adapter.all.value.length)
return {
all,
ids,
byId,
sorted,
isLoading,
isCreating,
isUpdating,
isDeleting,
error,
createError,
updateError,
deleteError,
isFresh,
count
}
}// stores/products/index.ts
import { createEntityAdapter } from '~/composables/useEntityAdapter'
import { createProductState } from './state'
import { createProductActions } from './actions'
import { createProductGetters } from './getters'
export const useProductStore = defineStore('products', () => {
const adapter = createEntityAdapter<Product>()
const state = reactive(createProductState())
const actions = createProductActions(adapter, state)
const getters = createProductGetters(adapter, state)
return {
...getters,
...actions
}
})<script setup lang="ts">
const store = useProductStore()
const toast = useToast()
onMounted(async () => {
const result = await store.fetchAll()
if (result.isErr()) {
toast.add({ title: result.error.message, color: 'error' })
}
})
async function handleUpdate(id: string) {
const result = await store.update(id, { price: 99.99 })
if (result.ok) {
toast.add({ title: 'Updated!', color: 'success' })
} else {
toast.add({ title: result.error.message, color: 'error' })
}
}
</script>
<template>
<div v-if="store.isLoading">Loading...</div>
<div v-else-if="store.error">Error: {{ store.error.message }}</div>
<div v-for="product in store.sorted" :key="product.id">
<h3>{{ product.name }}</h3>
<p>${{ product.price }}</p>
<UButton
:loading="store.isUpdating(product.id).value"
@click="handleUpdate(product.id)"
>
Update
</UButton>
</div>
</template>// server/utils/errors.ts
export class DatabaseError extends Error {
readonly type = 'database-error'
constructor(message: string, public cause?: unknown) {
super(message)
}
}
export class ValidationError extends Error {
readonly type = 'validation-error'
constructor(
message: string,
public fields?: Record<string, string[]>
) {
super(message)
}
}
export class AuthenticationError extends Error {
readonly type = 'authentication-error'
constructor(message = 'Unauthorized') {
super(message)
}
}
export class NotFoundError extends Error {
readonly type = 'not-found-error'
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`)
}
}
export class ConflictError extends Error {
readonly type = 'conflict-error'
constructor(message: string) {
super(message)
}
}// server/utils/db.ts
import { Result } from 'typescript-result'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from '../database/schema'
export const getDb = Result.wrap(
() => {
const sqlite = new Database('sqlite.db')
return drizzle(sqlite, { schema })
},
(error) => new DatabaseError('Failed to connect to database', error)
)
export function dbQuery<T>(
operation: (db: ReturnType<typeof drizzle>) => Promise<T>
): Promise<Result<T, DatabaseError>> {
return Result.gen(async function* () {
const db = yield* getDb()
const result = yield* await Result.wrap(
() => operation(db),
(error) => new DatabaseError('Database query failed', error)
)()
return result
})
}// server/repositories/products.repository.ts
import { Result } from 'typescript-result'
import { eq } from 'drizzle-orm'
import { products } from '../database/schema'
export class ProductRepository {
static async findAll(): Promise<Result<Product[], DatabaseError>> {
return dbQuery(async (db) => {
return db.select().from(products).all()
})
}
static async findById(id: string): Promise<Result<Product, NotFoundError | DatabaseError>> {
return Result.gen(async function* () {
const result = yield* await dbQuery(async (db) => {
return db.select().from(products).where(eq(products.id, id)).get()
})
if (!result) {
throw new NotFoundError('Product', id)
}
return result
})
}
static async create(
data: InsertProduct
): Promise<Result<Product, ValidationError | ConflictError | DatabaseError>> {
return Result.gen(async function* () {
// Validation
yield* ProductRepository.validate(data)
// Check duplicates
const existing = yield* await dbQuery(async (db) => {
return db.select().from(products).where(eq(products.name, data.name)).get()
})
if (existing) {
throw new ConflictError(`Product "${data.name}" already exists`)
}
// Insert
const created = yield* await dbQuery(async (db) => {
return db.insert(products).values(data).returning().get()
})
return created
})
}
static async update(
id: string,
data: Partial<InsertProduct>
): Promise<Result<Product, NotFoundError | ValidationError | DatabaseError>> {
return Result.gen(async function* () {
yield* await ProductRepository.findById(id)
if (Object.keys(data).length > 0) {
yield* ProductRepository.validatePartial(data)
}
const updated = yield* await dbQuery(async (db) => {
return db
.update(products)
.set({ ...data, updatedAt: new Date() })
.where(eq(products.id, id))
.returning()
.get()
})
return updated
})
}
static async delete(id: string): Promise<Result<void, NotFoundError | DatabaseError>> {
return Result.gen(async function* () {
yield* await ProductRepository.findById(id)
yield* await dbQuery(async (db) => {
await db.delete(products).where(eq(products.id, id))
})
})
}
private static validate(data: InsertProduct): Result<InsertProduct, ValidationError> {
return Result.try(
() => {
const errors: Record<string, string[]> = {}
if (!data.name || data.name.trim().length === 0) {
errors.name = ['Name is required']
}
if (data.price === undefined || data.price < 0) {
errors.price = ['Price must be positive']
}
if (Object.keys(errors).length > 0) {
throw new ValidationError('Validation failed', errors)
}
return data
},
(error) => error as ValidationError
)
}
private static validatePartial(
data: Partial<InsertProduct>
): Result<Partial<InsertProduct>, ValidationError> {
return Result.try(
() => {
const errors: Record<string, string[]> = {}
if (data.name !== undefined && data.name.trim().length === 0) {
errors.name = ['Name cannot be empty']
}
if (data.price !== undefined && data.price < 0) {
errors.price = ['Price must be positive']
}
if (Object.keys(errors).length > 0) {
throw new ValidationError('Validation failed', errors)
}
return data
},
(error) => error as ValidationError
)
}
}// server/api/products/index.get.ts
export default defineEventHandler(async (event) => {
const result = await ProductRepository.findAll()
if (result.ok) {
return result.value
}
throw createError({
statusCode: 500,
message: result.error.message
})
})// server/api/products/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = await ProductRepository.create(body)
return result
.match()
.when(ValidationError, (error) => {
throw createError({
statusCode: 400,
message: error.message,
data: error.fields
})
})
.when(ConflictError, (error) => {
throw createError({
statusCode: 409,
message: error.message
})
})
.when(DatabaseError, (error) => {
throw createError({
statusCode: 500,
message: 'Internal server error'
})
})
.otherwise(() => result.value)
.run()
})// server/api/products/[id].patch.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const body = await readBody(event)
const result = await ProductRepository.update(id, body)
if (result.ok) {
return result.value
}
const errorMap = {
'not-found-error': 404,
'validation-error': 400,
'database-error': 500
}
const error = result.error as any
throw createError({
statusCode: errorMap[error.type] ?? 500,
message: error.message,
data: error.fields
})
})// server/utils/handleResult.ts
import { Result } from 'typescript-result'
export function handleResult<T>(result: Result<T, Error>): T | never {
if (result.ok) {
return result.value
}
const statusMap: Record<string, number> = {
'validation-error': 400,
'authentication-error': 401,
'authorization-error': 403,
'not-found-error': 404,
'conflict-error': 409,
'database-error': 500
}
const error = result.error as any
const statusCode = statusMap[error.type] ?? 500
throw createError({
statusCode,
message: error.message,
data: error.fields ?? error.cause
})
}
// Usage
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const result = await ProductRepository.findById(id)
return handleResult(result)
})| Context | Use | Don't Use |
|---|---|---|
| Pinia Store Actions | $api with Result.wrap |
useFetch / useAsyncData |
| Component (SSR data) | useFetch / useAsyncData |
Store actions |
| Component (mutations) | Store actions | Direct $api |
| Event handlers | Store actions or $api |
useFetch |
| Server routes | $fetch or database |
N/A |
// In Pinia store - CORRECT
export const useProductStore = defineStore('products', () => {
const { $api } = useNuxtApp()
const fetchProductsApi = Result.wrap(
() => $api<Product[]>('/products'),
(error) => new ApiError('Failed to fetch', error)
)
async function fetchAll(): Promise<Result<Product[], ApiError>> {
return withAsyncState(state.fetch, async () => {
return await fetchProductsApi()
})
}
return { fetchAll }
})<!-- In component - CORRECT for SSR -->
<script setup lang="ts">
// Use useFetch for initial SSR data
const { data, error } = await useFetch('/api/products', {
onResponse({ response }) {
// Optionally sync to store
useProductStore().syncProducts(response._data)
}
})
// Use store actions for mutations
async function handleCreate() {
const result = await useProductStore().create(formData.value)
}
</script>// Using .match() with custom errors
const result = await someOperation()
result
.match()
.when(ValidationError, (error) => {
toast.add({
title: 'Validation failed',
description: error.message,
color: 'warning'
})
})
.when(ApiError, (error) => {
toast.add({
title: 'API error',
description: error.message,
color: 'error'
})
})
.when(NetworkError, () => {
toast.add({
title: 'Network error',
description: 'Please check your connection',
color: 'error'
})
})
.otherwise((error) => {
console.error('Unexpected error:', error)
})
.run()const result = await someOperation()
if (result.ok) {
console.log('Success:', result.value)
} else {
console.error('Error:', result.error)
}const result = await someOperation()
.mapError((error) => {
// Transform error
return new CustomError(error.message)
})-
Use
Result.wrapfor reusable API wrappersconst fetchUser = Result.wrap( (id: string) => $api<User>(`/users/${id}`), (error) => new ApiError('Failed', error) )
-
Use
Result.genfor complex flowsconst result = await Result.gen(async function* () { const user = yield* await fetchUser(id) const posts = yield* await fetchPosts(user.id) return { user, posts } })
-
Use
withAsyncStateto reduce boilerplatereturn withAsyncState( state, async () => await operation(), { optimistic: { apply, rollback }, onSuccess: () => {}, onError: () => {} } )
-
Create custom error types
class MyError extends Error { readonly type = 'my-error' }
-
Use
$apiin Pinia stores, notuseFetch -
Handle both success and error cases
-
Use pattern matching for specific error handling
-
Don't wrap
Result.genin try-catch// ❌ WRONG try { const result = await Result.gen(async function* () { // ... }) } catch (error) { // This won't catch Result errors! }
-
Don't use
useFetchin Pinia stores// ❌ WRONG - This will cause warnings export const useStore = defineStore('store', () => { async function fetch() { const { data } = await useFetch('/api/data') return data.value } })
-
Don't forget to handle errors
// ❌ WRONG - No error handling const result = await operation() console.log(result.value) // Might be undefined!
-
Don't use
.unwrap()without error handling// ❌ WRONG - Can throw const value = result.unwrap() // ✅ CORRECT if (result.ok) { const value = result.value }
-
Don't mix Result with try-catch
// ❌ WRONG - Defeats the purpose try { const result = await operation() if (result.ok) { return result.value } } catch (error) { // ... }
export function useRetryableRequest<T, E = Error>(
maxRetries = 3,
delayMs = 1000
) {
const requestState = reactive(createAsyncState<E>())
async function execute(operation: () => Promise<T>): Promise<Result<T, E>> {
const result = await withAsyncState(requestState, operation)
if (result.isErr() && requestState.retryCount < maxRetries) {
await new Promise(resolve =>
setTimeout(resolve, delayMs * requestState.retryCount)
)
return execute(operation)
}
return result
}
return { execute, retryCount: computed(() => requestState.retryCount) }
}export function useFormSubmit<T>() {
const submitState = reactive(createAsyncState<ValidationError | ApiError>())
async function submit(
data: T,
onSubmit: (data: T) => Promise<void>
): Promise<Result<void, ValidationError | ApiError>> {
return withAsyncState(
submitState,
() => onSubmit(data),
{
onSuccess: () => {
toast.add({ title: 'Success!', color: 'success' })
},
onError: (error) => {
if (error instanceof ValidationError) {
toast.add({ title: error.message, color: 'warning' })
} else {
toast.add({ title: 'Failed', color: 'error' })
}
}
}
)
}
return {
isSubmitting: computed(() => submitState.loading),
error: computed(() => submitState.error),
submit
}
}export function usePagination<T>(fetchPage: (page: number) => Promise<T[]>) {
const pages = ref(new Map<number, T[]>())
const pageStates = reactive(new Map<number, AsyncState<ApiError>>())
function getPageState(page: number) {
if (!pageStates.has(page)) {
pageStates.set(page, createAsyncState<ApiError>())
}
return pageStates.get(page)!
}
async function fetch(page: number): Promise<Result<T[], ApiError>> {
return withAsyncState(
getPageState(page),
async () => {
const data = await fetchPage(page)
pages.value.set(page, data)
return data
}
)
}
return {
getPage: (page: number) => computed(() => pages.value.get(page) ?? []),
isLoading: (page: number) => computed(() => getPageState(page).loading),
fetch
}
}export function useFileUpload() {
const uploadState = reactive(createAsyncState<UploadError>())
const progress = ref(0)
async function upload(file: File): Promise<Result<UploadedFile, UploadError>> {
progress.value = 0
return withAsyncState(
uploadState,
async () => {
const formData = new FormData()
formData.append('file', file)
const { $api } = useNuxtApp()
return await $api<UploadedFile>('/upload', {
method: 'POST',
body: formData,
onUploadProgress: (e) => {
progress.value = Math.round((e.loaded * 100) / (e.total ?? 100))
}
})
},
{
onFinally: () => {
progress.value = 0
}
}
)
}
return {
isUploading: computed(() => uploadState.loading),
progress: readonly(progress),
upload
}
}// BAD
try {
const result = await operation()
if (result.ok) {
return result.value
}
} catch (error) {
console.error(error)
}
// GOOD
const result = await operation()
if (result.ok) {
return result.value
} else {
console.error(result.error)
}// BAD
async function fetchData() {
const result = await api.fetch()
return result.value // Might be undefined!
}
// GOOD
async function fetchData() {
const result = await api.fetch()
if (result.ok) {
return result.value
} else {
throw result.error // Or handle appropriately
}
}// BAD
export const useStore = defineStore('store', () => {
async function loadData() {
const { data } = await useFetch('/api/data') // Warning!
return data.value
}
})
// GOOD
export const useStore = defineStore('store', () => {
async function loadData() {
const { $api } = useNuxtApp()
return await $api('/api/data')
}
})// BAD
async function fetchData() {
const state = reactive(createAsyncState()) // New state every time!
return withAsyncState(state, async () => {
// ...
})
}
// GOOD
const state = reactive(createAsyncState()) // Reuse
async function fetchData() {
return withAsyncState(state, async () => {
// ...
})
}// Check status
result.ok // boolean
result.isOk() // boolean
result.isErr() // boolean
// Access values
result.value // T if ok, undefined if error
result.error // E if error, undefined if ok
// Transform
result.map(fn) // Transform success value
result.mapError(fn) // Transform error
result.mapCatching(fn, errorFn) // Transform with error handling
// Pattern matching
result.match()
.when(ErrorType, handler)
.otherwise(handler)
.run()
// Unsafe (throws)
result.unwrap() // Get value or throw
result.unwrapOr(default) // Get value or default- Use
EntityAdapterfor normalized data - Use
createAsyncStatefor operation states - Use
withAsyncStateto reduce boilerplate - Wrap API calls with
Result.wrap - Use
Result.genfor complex flows - Handle optimistic updates with rollback
- Implement cache invalidation
- Expose readonly state
- Create separate loading/error states per operation
- Define custom error types
- Use repository pattern
- Wrap database queries with
Result - Validate input data
- Map errors to HTTP status codes
- Use
handleResultutility - Implement transaction support
- Return
Resultfrom all operations
This guide provides a complete baseline for building type-safe Nuxt applications with typescript-result. Key takeaways:
- No try-catch needed - Let the library handle errors
- Type-safe error handling - Errors tracked in types
- Consistent patterns - Same approach everywhere
- Reduced boilerplate -
withAsyncStatesaves 50-70% code - Optimistic updates - Built-in rollback support
- Clear separation - Client vs server patterns
- Composable - Chain operations elegantly
Use this guide as reference when:
- Creating new features
- Updating existing code
- Reviewing code quality
- Onboarding team members
- Debugging error handling
Remember: The goal is explicit, type-safe error handling that makes your code more maintainable and less error-prone.