Skip to main content

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:
ErrorStatusCode
BadRequest400BAD_REQUEST
Unauthorized401UNAUTHORIZED
Forbidden403FORBIDDEN
NotFound404NOT_FOUND
Conflict409CONFLICT
UnprocessableEntity422UNPROCESSABLE_ENTITY
InternalServerError500INTERNAL_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", ... }

Error Response Format

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

Error codes should be SCREAMING_SNAKE_CASE and clearly describe the problem:
  • TODO_NOT_FOUND, INVALID_EMAIL_FORMAT, INSUFFICIENT_PERMISSIONS
  • ERROR1, FAIL, BAD
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()
  }
});
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)
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
Be careful not to leak sensitive data in error messages or details:
// ❌ Bad - leaks user data
throw err.appError("Unauthorized", {
  details: { hashedPassword: user.password }
});

// ✅ Good - safe information only
throw err.appError("Unauthorized", {
  details: { userId: user.id }
});
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
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