Skip to content

Instantly share code, notes, and snippets.

@nandordudas
Last active November 2, 2025 22:06
Show Gist options
  • Select an option

  • Save nandordudas/8c4b97a3bedef8c7fe51cc23fbb907ab to your computer and use it in GitHub Desktop.

Select an option

Save nandordudas/8c4b97a3bedef8c7fe51cc23fbb907ab to your computer and use it in GitHub Desktop.

Nuxt + TypeScript Result - Comprehensive Baseline Guide

A complete reference for building type-safe, error-handled Nuxt applications using typescript-result

Example repo

Table of Contents

  1. Core Principles
  2. Installation & Setup
  3. Client-Side Patterns
  4. Server-Side Patterns
  5. State Management
  6. API Integration
  7. Error Handling
  8. Best Practices
  9. Common Patterns
  10. Anti-Patterns

Core Principles

Why typescript-result?

  • 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

Key Functions

// 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 }
})

Critical Rules

  1. Never wrap Result.gen in try-catch - The library already handles errors
  2. Use yield* in generators - Automatically unwraps Result values
  3. Use Result.wrap for reusable functions - Creates a wrapper you can call multiple times
  4. Use Result.try for one-off operations - Inside generators or standalone
  5. Always handle both ok and error cases - Use .match(), .mapError(), or check .ok

Installation & Setup

npm install typescript-result

TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true
  }
}

Client-Side Patterns

1. Custom Error Types

// 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)
  }
}

2. API Plugin with Result

// 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
    }
  }
})

3. Composable for API Calls (Optional)

// 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
  })
}

4. Entity Adapter Pattern

// 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
  }
}

5. Async State Management

// 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>())
}

State Management

Pinia Store Architecture

Store Structure

stores/
  products/
    index.ts       # Store assembly
    state.ts       # State types and creation
    actions.ts     # Actions with Result
    getters.ts     # Computed getters

1. State Layer

// 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()
  }
}

2. Actions Layer

// 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
  }
}

3. Getters Layer

// 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
  }
}

4. Store Assembly

// 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
  }
})

Component Usage

<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-Side Patterns

1. Server Error Types

// 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)
  }
}

2. Database Wrapper

// 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
  })
}

3. Repository Pattern

// 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
    )
  }
}

4. API Route Handlers

// 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
  })
})

5. Result Handler Utility

// 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)
})

API Integration

When to Use What

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

Client-Side API Pattern

// 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>

Error Handling

Pattern Matching

// 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()

Simple Check

const result = await someOperation()

if (result.ok) {
  console.log('Success:', result.value)
} else {
  console.error('Error:', result.error)
}

Error Transformation

const result = await someOperation()
  .mapError((error) => {
    // Transform error
    return new CustomError(error.message)
  })

Best Practices

✅ DO

  1. Use Result.wrap for reusable API wrappers

    const fetchUser = Result.wrap(
      (id: string) => $api<User>(`/users/${id}`),
      (error) => new ApiError('Failed', error)
    )
  2. Use Result.gen for complex flows

    const result = await Result.gen(async function* () {
      const user = yield* await fetchUser(id)
      const posts = yield* await fetchPosts(user.id)
      return { user, posts }
    })
  3. Use withAsyncState to reduce boilerplate

    return withAsyncState(
      state,
      async () => await operation(),
      {
        optimistic: { apply, rollback },
        onSuccess: () => {},
        onError: () => {}
      }
    )
  4. Create custom error types

    class MyError extends Error {
      readonly type = 'my-error'
    }
  5. Use $api in Pinia stores, not useFetch

  6. Handle both success and error cases

  7. Use pattern matching for specific error handling

❌ DON'T

  1. Don't wrap Result.gen in try-catch

    // ❌ WRONG
    try {
      const result = await Result.gen(async function* () {
        // ...
      })
    } catch (error) {
      // This won't catch Result errors!
    }
  2. Don't use useFetch in Pinia stores

    // ❌ WRONG - This will cause warnings
    export const useStore = defineStore('store', () => {
      async function fetch() {
        const { data } = await useFetch('/api/data')
        return data.value
      }
    })
  3. Don't forget to handle errors

    // ❌ WRONG - No error handling
    const result = await operation()
    console.log(result.value) // Might be undefined!
  4. Don't use .unwrap() without error handling

    // ❌ WRONG - Can throw
    const value = result.unwrap()
    
    // ✅ CORRECT
    if (result.ok) {
      const value = result.value
    }
  5. Don't mix Result with try-catch

    // ❌ WRONG - Defeats the purpose
    try {
      const result = await operation()
      if (result.ok) {
        return result.value
      }
    } catch (error) {
      // ...
    }

Common Patterns

1. Retry Logic

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) }
}

2. Form Submission

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
  }
}

3. Pagination

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
  }
}

4. File Upload

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
  }
}

Anti-Patterns

❌ Mixing Result with try-catch

// 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)
}

❌ Not handling errors

// 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
  }
}

❌ Using useFetch in stores

// 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')
  }
})

❌ Creating new AsyncState per call

// 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 () => {
    // ...
  })
}

Quick Reference

Result Methods

// 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

State Management Checklist

  • Use EntityAdapter for normalized data
  • Use createAsyncState for operation states
  • Use withAsyncState to reduce boilerplate
  • Wrap API calls with Result.wrap
  • Use Result.gen for complex flows
  • Handle optimistic updates with rollback
  • Implement cache invalidation
  • Expose readonly state
  • Create separate loading/error states per operation

Server-Side Checklist

  • Define custom error types
  • Use repository pattern
  • Wrap database queries with Result
  • Validate input data
  • Map errors to HTTP status codes
  • Use handleResult utility
  • Implement transaction support
  • Return Result from all operations

Conclusion

This guide provides a complete baseline for building type-safe Nuxt applications with typescript-result. Key takeaways:

  1. No try-catch needed - Let the library handle errors
  2. Type-safe error handling - Errors tracked in types
  3. Consistent patterns - Same approach everywhere
  4. Reduced boilerplate - withAsyncState saves 50-70% code
  5. Optimistic updates - Built-in rollback support
  6. Clear separation - Client vs server patterns
  7. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment