Skip to main content

Error Schema Handling

When using useDiscriminatorResponse: true mode with @navios/builder, API error responses are returned as data instead of being thrown. This enables type-safe error discrimination in your components.

Overview

By default, HTTP errors (4xx, 5xx) throw exceptions. With discriminator mode, they become part of your response type, allowing you to handle different error cases in a type-safe way.

Setting Up Discriminator Mode

First, configure your builder to use discriminator response mode:

import { builder } from '@navios/builder'
import { create } from '@navios/http'
import { declareClient } from '@navios/react-query'

// Enable discriminator mode
const api = builder({ useDiscriminatorResponse: true })
api.provideClient(create({ baseURL: 'https://api.example.com' }))

// Create client with discriminator type parameter
const client = declareClient<true>({ api })

Defining Error Schemas

Use errorSchema to define the shape of error responses by status code:

const getUser = client.query({
method: 'GET',
url: '/users/$userId',
responseSchema: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
errorSchema: {
400: z.object({
error: z.string(),
code: z.number(),
}),
404: z.object({
notFound: z.literal(true),
message: z.string(),
}),
500: z.object({
serverError: z.string(),
traceId: z.string(),
}),
},
processResponse: (data) => {
// data is typed as the union of all possible responses
// User | { error, code } | { notFound, message } | { serverError, traceId }
return data
},
})

Processing Responses

The processResponse callback receives the union of success and error types. Transform this into a result type:

Result Pattern

const getUser = client.query({
method: 'GET',
url: '/users/$userId',
responseSchema: userSchema,
errorSchema: {
400: z.object({ error: z.string() }),
404: z.object({ notFound: z.literal(true) }),
},
processResponse: (data) => {
// Check for error shapes
if ('error' in data) {
return { ok: false as const, error: data.error }
}
if ('notFound' in data) {
return { ok: false as const, error: 'User not found' }
}
// Success case
return { ok: true as const, user: data }
},
})

Using in Components

function UserProfile({ userId }: { userId: string }) {
const result = getUser.useSuspense({ urlParams: { userId } })

if (!result.ok) {
return <ErrorMessage error={result.error} />
}

return (
<div>
<h1>{result.user.name}</h1>
<p>{result.user.email}</p>
</div>
)
}

Mutations with Error Schemas

The same pattern works for mutations:

const createUser = client.mutation({
method: 'POST',
url: '/users',
requestSchema: z.object({
name: z.string(),
email: z.string().email(),
}),
responseSchema: userSchema,
errorSchema: {
400: z.object({
validationErrors: z.array(z.object({
field: z.string(),
message: z.string(),
})),
}),
409: z.object({
conflict: z.literal(true),
existingId: z.string(),
}),
},
processResponse: (data) => {
if ('validationErrors' in data) {
return {
ok: false as const,
type: 'validation' as const,
errors: data.validationErrors,
}
}
if ('conflict' in data) {
return {
ok: false as const,
type: 'conflict' as const,
existingId: data.existingId,
}
}
return { ok: true as const, user: data }
},
})

Using Mutation Results

function CreateUserForm() {
const { mutateAsync, isPending } = createUser()
const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})

const handleSubmit = async (formData: FormData) => {
setError(null)
setFieldErrors({})

const result = await mutateAsync({
data: {
name: formData.get('name') as string,
email: formData.get('email') as string,
},
})

if (!result.ok) {
if (result.type === 'validation') {
const errors: Record<string, string> = {}
result.errors.forEach((e) => {
errors[e.field] = e.message
})
setFieldErrors(errors)
} else if (result.type === 'conflict') {
setError(`User already exists: ${result.existingId}`)
}
return
}

// Success - navigate to user page
navigate(`/users/${result.user.id}`)
}

return (
<form onSubmit={handleSubmit}>
{error && <div className="error">{error}</div>}
<input name="name" />
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
<input name="email" type="email" />
{fieldErrors.email && <span className="field-error">{fieldErrors.email}</span>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</button>
</form>
)
}

Status Code Access

Error responses include a __status property for accessing the HTTP status code:

const getUser = client.query({
// ...
processResponse: (data) => {
if ('error' in data) {
// Access the status code
const status = (data as any).__status // 400, 404, or 500
console.log(`Error with status ${status}: ${data.error}`)
return { ok: false as const, error: data.error, status }
}
return { ok: true as const, user: data }
},
})

Error Handling vs Error Schema

Understanding when to use each approach:

Error Schema (Discriminator Mode)

Use when:

  • API returns structured error responses
  • You need to discriminate between different error types
  • You want to display error-specific UI
  • Validation errors need to be shown inline
// Good: API returns structured validation errors
errorSchema: {
400: z.object({
errors: z.array(z.object({
field: z.string(),
message: z.string(),
})),
}),
}

Traditional Error Handling (onFail)

Use when:

  • Errors should be logged but not displayed differently
  • You want errors to throw and be caught by error boundaries
  • Simple error toast/notification is sufficient
const getUser = client.query({
method: 'GET',
url: '/users/$userId',
responseSchema: userSchema,
onFail: (error) => {
// Log error, show toast, etc.
// Error is still thrown
console.error('Failed to fetch user:', error)
toast.error('Failed to load user')
},
})

Best Practices

Use Discriminant Properties

Design your error schemas with clear discriminant properties:

// ✅ Good - clear discriminant
errorSchema: {
400: z.object({ type: z.literal('validation'), errors: z.array(...) }),
404: z.object({ type: z.literal('not_found'), resource: z.string() }),
500: z.object({ type: z.literal('server_error'), message: z.string() }),
}

// ❌ Ambiguous - hard to discriminate
errorSchema: {
400: z.object({ message: z.string() }),
404: z.object({ message: z.string() }),
500: z.object({ message: z.string() }),
}

Consistent Result Types

Use a consistent result type across your application:

// types.ts
type Result<T, E = string> =
| { ok: true; data: T }
| { ok: false; error: E }

// In your queries
processResponse: (data): Result<User, string> => {
if ('error' in data) {
return { ok: false, error: data.error }
}
return { ok: true, data: data }
}

Create Reusable Error Handlers

For common error patterns, create helper functions:

// helpers.ts
function handleApiResponse<T>(
data: T | { error: string } | { notFound: true },
successKey: keyof T,
): Result<T> {
if ('error' in data) {
return { ok: false, error: data.error }
}
if ('notFound' in data) {
return { ok: false, error: 'Not found' }
}
return { ok: true, data }
}

// Usage
processResponse: (data) => handleApiResponse(data, 'id')

Complete Example

// queries.ts
const api = builder({ useDiscriminatorResponse: true })
api.provideClient(create({ baseURL: '/api' }))

const client = declareClient<true>({ api })

// Common error schema for all endpoints
const commonErrorSchema = {
401: z.object({ unauthorized: z.literal(true) }),
403: z.object({ forbidden: z.literal(true), requiredRole: z.string() }),
500: z.object({ serverError: z.string(), traceId: z.string() }),
}

export const getUser = client.query({
method: 'GET',
url: '/users/$userId',
responseSchema: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
role: z.string(),
}),
errorSchema: {
...commonErrorSchema,
404: z.object({ notFound: z.literal(true) }),
},
processResponse: (data) => {
// Handle common errors
if ('unauthorized' in data) {
return { ok: false as const, type: 'unauthorized' as const }
}
if ('forbidden' in data) {
return { ok: false as const, type: 'forbidden' as const, requiredRole: data.requiredRole }
}
if ('serverError' in data) {
return { ok: false as const, type: 'server_error' as const, message: data.serverError }
}
// Endpoint-specific errors
if ('notFound' in data) {
return { ok: false as const, type: 'not_found' as const }
}
// Success
return { ok: true as const, user: data }
},
})
// UserProfile.tsx
function UserProfile({ userId }: { userId: string }) {
const result = getUser.useSuspense({ urlParams: { userId } })

if (!result.ok) {
switch (result.type) {
case 'unauthorized':
return <RedirectToLogin />
case 'forbidden':
return <AccessDenied requiredRole={result.requiredRole} />
case 'not_found':
return <NotFound message="User not found" />
case 'server_error':
return <ServerError message={result.message} />
}
}

return (
<div>
<h1>{result.user.name}</h1>
<p>{result.user.email}</p>
<Badge>{result.user.role}</Badge>
</div>
)
}

Next Steps