Overview
Contract Kit provides a comprehensive error handling system that distinguishes between validation errors (client mistakes) and domain errors (business logic violations). This guide covers how to effectively handle both types of errors in your applications.
Understanding Error Types
Contract Kit handles two distinct types of errors:
Validation Errors (400 Bad Request)
Validation errors occur when incoming request data doesn’t match your contract schemas. These are automatically handled by Contract Kit and returned as 400 errors.
{
" message " : " Invalid request body " ,
" error " : " title: String must contain at least 1 character(s) "
}
Domain Errors (AppError)
Domain errors represent business logic violations like “Todo not found” or “User not authorized”. These use the @contract-kit/errors package and can have custom HTTP status codes.
{
" code " : " TODO_NOT_FOUND " ,
" message " : " Todo not found " ,
" details " : { " id " : " abc123 " }
}
Validation errors are handled automatically by Contract Kit. This guide focuses primarily on domain error handling using AppError.
Setting Up Your Error Catalog
Start by creating a centralized error catalog for your application. This ensures consistency and type safety across your codebase.
Basic Error Catalog
// app/errors.ts
import { defineErrors, createErrorFactory } from " @contract-kit/errors " ;
export const errors = defineErrors ({
TodoNotFound : {
code : " TODO_NOT_FOUND " ,
status : 404 ,
message : " Todo not found " ,
},
TodoAlreadyCompleted : {
code : " TODO_ALREADY_COMPLETED " ,
status : 409 ,
message : " Todo is already completed " ,
},
Unauthorized : {
code : " UNAUTHORIZED " ,
status : 401 ,
message : " You must be signed in " ,
},
InsufficientPermissions : {
code : " INSUFFICIENT_PERMISSIONS " ,
status : 403 ,
message : " You don't have permission to perform this action " ,
},
});
export const err = createErrorFactory (errors);
Extending HTTP Errors
For common HTTP errors, start with the built-in httpErrors catalog:
// app/errors.ts
import { defineErrors, createErrorFactory } from " @contract-kit/errors " ;
import { httpErrors } from " @contract-kit/errors/http " ;
export const errors = defineErrors ({
... httpErrors, // BadRequest, Unauthorized, Forbidden, NotFound, etc.
TodoNotFound : {
code : " TODO_NOT_FOUND " ,
status : 404 ,
message : " Todo not found " ,
},
TodoAlreadyCompleted : {
code : " TODO_ALREADY_COMPLETED " ,
status : 409 ,
message : " Todo is already completed " ,
},
InvalidTodoState : {
code : " INVALID_TODO_STATE " ,
status : 422 ,
message : " Todo is in an invalid state " ,
},
});
export const err = createErrorFactory (errors);
The httpErrors catalog includes:
Error Status Code BadRequest400 BAD_REQUESTUnauthorized401 UNAUTHORIZEDForbidden403 FORBIDDENNotFound404 NOT_FOUNDConflict409 CONFLICTUnprocessableEntity422 UNPROCESSABLE_ENTITYInternalServerError500 INTERNAL_SERVER_ERROR
Throwing Errors in Use Cases
Once you have your error catalog, use the error factory to throw type-safe errors in your use cases.
Basic Error Throwing
// application/todos/getTodo.ts
import { err } from " @/app/errors " ;
import { useCase } from " ../use-case " ;
import { z } from " zod " ;
export const getTodo = useCase
. query ( " todos.get " )
. input (z. object ({ id : z. string () }))
. output (TodoSchema. nullable ())
. run ( async ({ ctx , input }) => {
const todo = await ctx.ports.db.todos. findById (input.id);
if ( ! todo) {
throw err. appError ( " TodoNotFound " , {
details : { id : input.id }
});
}
return todo;
});
Including Error Details
Add contextual information to help with debugging:
// application/todos/complete.ts
import { err } from " @/app/errors " ;
export const completeTodo = useCase
. command ( " todos.complete " )
. input (z. object ({ id : z. string () }))
. output (z. object ({ success : z. boolean () }))
. run ( async ({ ctx , input }) => {
const todo = await ctx.ports.db.todos. findById (input.id);
if ( ! todo) {
throw err. appError ( " TodoNotFound " , {
details : { id : input.id }
});
}
if (todo.completed) {
throw err. appError ( " TodoAlreadyCompleted " , {
details : {
id : input.id,
completedAt : todo.completedAt,
}
});
}
await ctx.ports.db.todos. update (input.id, {
completed : true ,
completedAt : new Date (),
});
return { success : true };
});
Custom Error Messages
Override the default message for specific contexts:
export const deleteTodo = useCase
. command ( " todos.delete " )
. input (z. object ({ id : z. string () }))
. output (z. object ({ success : z. boolean () }))
. run ( async ({ ctx , input }) => {
const todo = await ctx.ports.db.todos. findById (input.id);
if ( ! todo) {
throw err. appError ( " TodoNotFound " , {
details : { id : input.id },
message : `Cannot delete todo ${ input.id } : not found`
});
}
if (todo.shared && ! ctx.user.isAdmin) {
throw err. appError ( " InsufficientPermissions " , {
details : {
resource : " todo " ,
id : input.id,
required : " admin "
},
message : " Only admins can delete shared todos "
});
}
await ctx.ports.db.todos. delete (input.id);
return { success : true };
});
Automatic Error Handling
Contract Kit automatically handles AppError instances thrown from your handlers. No manual try-catch needed!
In Next.js Routes
// app/api/todos/[id]/route.ts
import { getTodoContract } from " @/contracts/todos " ;
import { getTodo } from " @/application/todos/get " ;
import { server } from " @/lib/server " ;
export const GET = server
. route (getTodoContract)
. useCase (getTodo, {
mapInput : ({ path }) => ({ id : path.id }),
mapOutput : ( result ) => result,
status : 200 ,
});
// If getTodo throws TodoNotFound error:
// Response: 404 { "code": "TODO_NOT_FOUND", "message": "Todo not found", ... }
All AppError instances are automatically converted to JSON responses:
{
" code " : " TODO_NOT_FOUND " ,
" message " : " Todo not found " ,
" details " : {
" id " : " abc123 "
}
}
The HTTP status code comes from the error definition in your catalog.
Handling Unexpected Errors
For truly unexpected errors (not AppError instances), use the onUnhandledError callback:
// lib/server.ts
import { createNextServer } from " @contract-kit/next " ;
import type { AppCtx } from " @/app/context " ;
export const server = await createNextServer < AppCtx >({
ports : {},
createContext : buildContext,
onUnhandledError : ( err , { req , ctx }) => {
// Log the error
console. error ( " Unexpected error: " , {
error : err,
requestId : ctx.requestId,
path : req.url,
method : req.method,
});
// Optional: Send to error tracking service
// await errorTracker.captureException(err);
// Return undefined to use default 500 response
return undefined ;
// Or return a custom response:
// return {
// status: 500,
// body: {
// message: "Internal server error",
// requestId: ctx.requestId
// }
// };
},
});
onUnhandledError is only for unexpected errors. AppError instances are handled automatically and won’t trigger this callback.
Client-Side Error Handling
Handle errors on the client using the typed client.
Basic Error Handling
" use client " ;
import { apiClient } from " @/lib/api-client " ;
import { isAppError } from " @contract-kit/errors " ;
import { useState } from " react " ;
export function TodoDetail ({ id } : { id : string }) {
const [error, setError] = useState < string | null >( null );
const [todo, setTodo] = useState ( null );
const loadTodo = async () => {
try {
const result = await apiClient. getTodo ({ path : { id } });
setTodo (result);
setError ( null );
} catch (err) {
if ( isAppError (err)) {
// Handle known application errors
setError (err.message);
} else if (err instanceof Error ) {
// Handle validation or network errors
setError (err.message);
} else {
setError ( " An unexpected error occurred " );
}
}
};
// ... rest of component
}
Handling Specific Error Codes
const completeTodo = async ( id : string ) => {
try {
await apiClient. completeTodo ({ path : { id } });
} catch (err) {
if ( isAppError (err)) {
switch (err.code) {
case " TODO_NOT_FOUND " :
showNotification ( " Todo not found " , " error " );
break ;
case " TODO_ALREADY_COMPLETED " :
showNotification ( " Todo is already completed " , " info " );
break ;
case " UNAUTHORIZED " :
router. push ( " /login " );
break ;
default :
showNotification (err.message, " error " );
}
} else {
showNotification ( " Network error " , " error " );
}
}
};
With React Query
import { useMutation } from " @tanstack/react-query " ;
import { apiClient } from " @/lib/api-client " ;
import { isAppError } from " @contract-kit/errors " ;
export function useTodoMutations () {
const completeTodo = useMutation ({
mutationFn : ( id : string ) =>
apiClient. completeTodo ({ path : { id } }),
onError : ( err ) => {
if ( isAppError (err)) {
if (err.code === " TODO_ALREADY_COMPLETED " ) {
// Handle gracefully
toast. info ( " Todo is already completed " );
} else {
toast. error (err.message);
}
} else {
toast. error ( " An unexpected error occurred " );
}
},
onSuccess : () => {
queryClient. invalidateQueries ({ queryKey : [ " todos " ] });
toast. success ( " Todo completed! " );
},
});
return { completeTodo };
}
Testing Error Scenarios
Write tests to ensure your error handling works correctly.
Testing Use Cases
import { describe, expect, it, vi } from " vitest " ;
import { getTodo } from " @/application/todos/get " ;
import { isAppError } from " @contract-kit/errors " ;
describe ( " getTodo " , () => {
it ( " throws TodoNotFound when todo does not exist " , async () => {
const mockDb = {
todos : {
findById : vi. fn (). mockResolvedValue ( null ),
},
};
const ctx = {
ports : { db : mockDb },
user : { id : " user-1 " , role : " user " },
};
await expect (
getTodo. run ({ ctx, input : { id : " nonexistent " } })
).rejects. toThrow ();
try {
await getTodo. run ({ ctx, input : { id : " nonexistent " } });
} catch (error) {
expect ( isAppError (error)). toBe ( true );
if ( isAppError (error)) {
expect (error.code). toBe ( " TODO_NOT_FOUND " );
expect (error.status). toBe ( 404 );
expect (error.details). toEqual ({ id : " nonexistent " });
}
}
});
it ( " returns todo when it exists " , async () => {
const mockTodo = {
id : " todo-1 " ,
title : " Test " ,
completed : false ,
createdAt : new Date (),
updatedAt : new Date (),
};
const mockDb = {
todos : {
findById : vi. fn (). mockResolvedValue (mockTodo),
},
};
const ctx = {
ports : { db : mockDb },
user : { id : " user-1 " , role : " user " },
};
const result = await getTodo. run ({
ctx,
input : { id : " todo-1 " }
});
expect (result). toEqual (mockTodo);
expect (mockDb.todos.findById). toHaveBeenCalledWith ( " todo-1 " );
});
});
Testing API Routes
import { describe, expect, it } from " vitest " ;
import { GET } from " @/app/api/todos/[id]/route " ;
describe ( " GET /api/todos/:id " , () => {
it ( " returns 404 when todo not found " , async () => {
const req = new Request ( " http://localhost/api/todos/nonexistent " , {
method : " GET " ,
});
const response = await GET (req);
expect (response.status). toBe ( 404 );
const body = await response. json ();
expect (body). toEqual ({
code : " TODO_NOT_FOUND " ,
message : " Todo not found " ,
details : { id : " nonexistent " },
});
});
it ( " returns 200 with todo when found " , async () => {
const req = new Request ( " http://localhost/api/todos/todo-1 " , {
method : " GET " ,
});
const response = await GET (req);
expect (response.status). toBe ( 200 );
const body = await response. json ();
expect (body). toHaveProperty ( " id " , " todo-1 " );
expect (body). toHaveProperty ( " title " );
});
});
Best Practices
Use descriptive error codes
Error codes should be SCREAMING_SNAKE_CASE and clearly describe the problem:
✅ TODO_NOT_FOUND, INVALID_EMAIL_FORMAT, INSUFFICIENT_PERMISSIONS
❌ ERROR1, FAIL, BAD
Include helpful error details
Add context in the details field to help with debugging: throw err. appError ( " TodoNotFound " , {
details : {
id : input.id,
userId : ctx.user.id,
timestamp : new Date (). toISOString ()
}
});
Map HTTP status codes correctly
Choose appropriate HTTP status codes:
400 - Client validation errors (bad input)
401 - Authentication required (not logged in)
403 - Authorization failed (logged in but no access)
404 - Resource not found
409 - Conflict (e.g., duplicate, state mismatch)
422 - Unprocessable entity (validation passed but business logic failed)
500 - Internal server error (unexpected errors)
Centralize error definitions
Keep all error definitions in one place (e.g., app/errors.ts) for consistency:
Easy to find and update
Prevents duplicate error codes
Ensures consistent error messages
Makes error codes discoverable
Don't expose sensitive information
Handle errors at the right level
Throw errors in use cases, not in ports or domain logic:
Domain : Pure business logic, return null/undefined for not found
Ports : Infrastructure interfaces, throw for connection errors
Use Cases : Business operations, throw AppError for business violations
Routes : Map use case results to HTTP responses
Test both success and error paths
Always test that your code handles errors correctly: describe ( " completeTodo " , () => {
it ( " should complete todo successfully " , async () => {
// Test happy path
});
it ( " should throw TodoNotFound " , async () => {
// Test error path
});
it ( " should throw TodoAlreadyCompleted " , async () => {
// Test another error path
});
});
Advanced Patterns
Conditional Error Throwing
export const updateTodo = useCase
. command ( " todos.update " )
. input (z. object ({
id : z. string (),
title : z. string (). optional (),
completed : z. boolean (). optional (),
}))
. output (TodoSchema)
. run ( async ({ ctx , input }) => {
const todo = await ctx.ports.db.todos. findById (input.id);
if ( ! todo) {
throw err. appError ( " TodoNotFound " , {
details : { id : input.id }
});
}
// Only owners or admins can update
if (todo.userId !== ctx.user.id && ctx.user.role !== " admin " ) {
throw err. appError ( " InsufficientPermissions " , {
details : {
resource : " todo " ,
id : input.id,
ownerId : todo.userId,
currentUserId : ctx.user.id
}
});
}
// Can't modify completed todos
if (todo.completed && input.title) {
throw err. appError ( " InvalidTodoState " , {
details : { id : input.id },
message : " Cannot modify title of completed todo "
});
}
const updated = await ctx.ports.db.todos. update (input.id, input);
return updated;
});
Error Recovery Strategies
export const getTodoWithFallback = useCase
. query ( " todos.getWithFallback " )
. input (z. object ({ id : z. string () }))
. output (TodoSchema. nullable ())
. run ( async ({ ctx , input }) => {
try {
const todo = await ctx.ports.db.todos. findById (input.id);
return todo;
} catch (error) {
// Log error but return null instead of throwing
console. error ( " Failed to fetch todo: " , error);
return null ;
}
});
Wrapping Third-Party Errors
// Example third-party error from an email service
class EmailServiceError extends Error {
constructor ( message : string ) {
super (message);
this .name = " EmailServiceError " ;
}
}
export const sendNotification = useCase
. command ( " notifications.send " )
. input (NotificationSchema)
. output (z. object ({ success : z. boolean () }))
. run ( async ({ ctx , input }) => {
try {
await ctx.ports.email. send (input);
return { success : true };
} catch (error) {
// Wrap third-party errors in AppError
if (error instanceof EmailServiceError ) {
throw err. appError ( " InternalServerError " , {
details : {
service : " email " ,
originalError : error.message
},
message : " Failed to send notification "
});
}
throw error;
}
});
Aggregating Multiple Errors
export const batchCreateTodos = useCase
. command ( " todos.batchCreate " )
. input (z. object ({
todos : z. array (CreateTodoSchema)
}))
. output (z. object ({
created : z. array (TodoSchema),
errors : z. array (z. object ({
index : z. number (),
error : z. string ()
}))
}))
. run ( async ({ ctx , input }) => {
const created = [];
const errors = [];
for ( let i = 0 ; i < input.todos.length; i ++ ) {
try {
const todo = await ctx.ports.db.todos. create (input.todos[i]);
created. push (todo);
} catch (error) {
if ( isAppError (error)) {
errors. push ({
index : i,
error : error.message
});
} else {
errors. push ({
index : i,
error : " Unknown error "
});
}
}
}
return { created, errors };
});
Next Steps