Files
gh-jezweb-claude-skills-ski…/references/typescript-patterns.md
2025-11-30 08:25:35 +08:00

5.3 KiB

TypeScript Patterns for TanStack Query

Type-safe query and mutation patterns


1. Basic Type Inference

type Todo = {
  id: number
  title: string
  completed: boolean
}

// ✅ Automatic type inference
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: async (): Promise<Todo[]> => {
    const response = await fetch('/api/todos')
    return response.json()
  },
})
// data is typed as Todo[] | undefined

2. Generic Query Hook

function useEntity<T>(
  endpoint: string,
  id: number
) {
  return useQuery({
    queryKey: [endpoint, id],
    queryFn: async (): Promise<T> => {
      const response = await fetch(`/api/${endpoint}/${id}`)
      return response.json()
    },
  })
}

// Usage
const { data } = useEntity<User>('users', 1)
// data: User | undefined

3. queryOptions with Type Safety

export const todosQueryOptions = queryOptions({
  queryKey: ['todos'],
  queryFn: async (): Promise<Todo[]> => {
    const response = await fetch('/api/todos')
    return response.json()
  },
  staleTime: 1000 * 60,
})

// Perfect type inference everywhere
useQuery(todosQueryOptions)
useSuspenseQuery(todosQueryOptions)
queryClient.prefetchQuery(todosQueryOptions)

4. Mutation with Types

type CreateTodoInput = {
  title: string
}

type CreateTodoResponse = Todo

const { mutate } = useMutation<
  CreateTodoResponse, // TData
  Error, // TError
  CreateTodoInput, // TVariables
  { previous?: Todo[] } // TContext
>({
  mutationFn: async (input) => {
    const response = await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify(input),
    })
    return response.json()
  },
})

// Type-safe mutation
mutate({ title: 'New todo' })

5. Custom Error Types

class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code: string
  ) {
    super(message)
  }
}

const { data, error } = useQuery<Todo[], ApiError>({
  queryKey: ['todos'],
  queryFn: async () => {
    const response = await fetch('/api/todos')
    if (!response.ok) {
      throw new ApiError(
        'Failed to fetch',
        response.status,
        'FETCH_ERROR'
      )
    }
    return response.json()
  },
})

if (error) {
  // error.status and error.code are typed
}

6. Zod Schema Validation

import { z } from 'zod'

const TodoSchema = z.object({
  id: z.number(),
  title: z.string(),
  completed: z.boolean(),
})

type Todo = z.infer<typeof TodoSchema>

const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: async () => {
    const response = await fetch('/api/todos')
    const json = await response.json()
    return TodoSchema.array().parse(json) // Runtime + compile time safety
  },
})

7. Discriminated Union for Status

type QueryState<T> =
  | { status: 'pending'; data: undefined; error: null }
  | { status: 'error'; data: undefined; error: Error }
  | { status: 'success'; data: T; error: null }

function useTypedQuery<T>(
  queryKey: string[],
  queryFn: () => Promise<T>
): QueryState<T> {
  const { data, status, error } = useQuery({ queryKey, queryFn })

  return {
    status,
    data: data as any,
    error: error as any,
  }
}

// Usage with exhaustive checking
const result = useTypedQuery(['todos'], fetchTodos)

switch (result.status) {
  case 'pending':
    return <Loading />
  case 'error':
    return <Error error={result.error} /> // error is typed
  case 'success':
    return <TodoList todos={result.data} /> // data is typed
}

8. Type-Safe Query Keys

// Define all query keys in one place
const queryKeys = {
  todos: {
    all: ['todos'] as const,
    lists: () => [...queryKeys.todos.all, 'list'] as const,
    list: (filters: TodoFilters) =>
      [...queryKeys.todos.lists(), filters] as const,
    details: () => [...queryKeys.todos.all, 'detail'] as const,
    detail: (id: number) =>
      [...queryKeys.todos.details(), id] as const,
  },
}

// Usage
useQuery({
  queryKey: queryKeys.todos.detail(1),
  queryFn: () => fetchTodo(1),
})

queryClient.invalidateQueries({
  queryKey: queryKeys.todos.all
})

9. Utility Types

import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'

// Extract query data type
type TodosQuery = UseQueryResult<Todo[]>
type TodoData = TodosQuery['data'] // Todo[] | undefined

// Extract mutation types
type AddTodoMutation = UseMutationResult<
  Todo,
  Error,
  CreateTodoInput
>

10. Strict Null Checks

const { data } = useQuery({
  queryKey: ['todo', id],
  queryFn: () => fetchTodo(id),
})

// ❌ TypeScript error if strictNullChecks enabled
const title = data.title

// ✅ Proper null handling
const title = data?.title ?? 'No title'

// ✅ Type guard
if (data) {
  const title = data.title // data is Todo, not undefined
}

11. SuspenseQuery Types

const { data } = useSuspenseQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})

// data is ALWAYS Todo[], never undefined
// No need for undefined checks with suspense
data.map(todo => todo.title) // ✅ Safe

Best Practices

Always type queryFn return value Use const assertions for query keys Leverage queryOptions for reusability Use Zod for runtime + compile time validation Enable strict null checks Create type-safe query key factories Use custom error types for better error handling