Skip to main content

Overview

@contract-kit/errors provides a type-safe error catalog system and error handling utilities for building robust APIs. It includes an AppError class for domain/application errors and automatic integration with @contract-kit/next.
Prefer the contract-kit meta package for new projects. It re-exports @contract-kit/errors along with core, client, application, and more.

Installation

npm install @contract-kit/errors

Features

  • Error Catalog - Define application errors in a central location with HTTP status codes
  • Type-Safe Errors - Full TypeScript support for error definitions
  • AppError Class - Throwable error class for domain and application code
  • Error Factory - Type-safe factory for creating errors from your catalog
  • Next.js Integration - Automatic error handling in @contract-kit/next
  • HTTP Error Helpers - Pre-built catalog of common HTTP errors

Usage

Defining an Error Catalog

Create a central error catalog for your application:
// 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",
  },
  Unauthorized: {
    code: "UNAUTHORIZED",
    status: 401,
    message: "You must be signed in",
  },
  BadRequest: {
    code: "BAD_REQUEST",
    status: 400,
    message: "Invalid request",
  },
  TodoAlreadyCompleted: {
    code: "TODO_ALREADY_COMPLETED",
    status: 409,
    message: "Todo is already completed",
  },
});

export const err = createErrorFactory(errors);

Throwing Errors in Use Cases

Use the error factory to throw type-safe errors:
// application/todos/getTodo.ts
import { err } from "@/app/errors";
import { useCase } from "../use-case";

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) {
      // Throws an AppError with the defined status and message
      throw err.appError("TodoNotFound", { 
        details: { id: input.id } 
      });
    }
    
    return todo;
  });

Adding Error Details

You can include additional context in error details:
// 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 });
    return { success: true };
  });

Error Response Format

When an AppError is thrown, it’s automatically converted to a JSON response:
{
  "code": "TODO_NOT_FOUND",
  "message": "Todo not found",
  "details": { "id": "abc123" }
}
The HTTP status code is taken from the error definition (e.g., 404 for TodoNotFound).

HTTP Error Helpers

Start from a base catalog of common HTTP errors:
// app/errors.ts
import { defineErrors } from "@contract-kit/errors";
import { httpErrors } from "@contract-kit/errors/http";

export const errors = defineErrors({
  ...httpErrors,
  // Add your domain-specific errors
  TodoNotFound: {
    code: "TODO_NOT_FOUND",
    status: 404,
    message: "Todo not found",
  },
  TodoAlreadyCompleted: {
    code: "TODO_ALREADY_COMPLETED",
    status: 409,
    message: "Todo is already completed",
  },
});

Available HTTP Errors

The httpErrors catalog includes:
ErrorStatusCode
BadRequest400BAD_REQUEST
Unauthorized401UNAUTHORIZED
Forbidden403FORBIDDEN
NotFound404NOT_FOUND
Conflict409CONFLICT
UnprocessableEntity422UNPROCESSABLE_ENTITY
InternalServerError500INTERNAL_SERVER_ERROR

Next.js Integration

AppError instances are automatically handled by @contract-kit/next:
// lib/server.ts
import { createNextServer } from "@contract-kit/next";
import type { AppCtx } from "@/app/context";

export const server = await createNextServer<AppCtx>({
  ports: {},
  createContext: buildContext,
  // Optional: handle unexpected errors
  onUnhandledError: (err, { req, ctx }) => {
    console.error("Unexpected error:", err);
    // Return undefined to use the default 500 response
    return undefined;
  },
});
When an AppError is thrown from a handler:
  1. It’s automatically caught by the Next.js adapter
  2. Converted to a JSON response with the error’s status code
  3. Returned to the client with the standard format
// 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", ... }

API Reference

defineErrors<T>(defs: T): T

Type-preserving helper to define an error catalog. Returns the same object with full type information.
const errors = defineErrors({
  MyError: {
    code: "MY_ERROR",
    status: 400,
    message: "Something went wrong",
  },
});

createErrorFactory<T>(catalog: T): ErrorFactory<T>

Creates an error factory bound to a specific catalog.
const err = createErrorFactory(errors);

// Type-safe error creation
throw err.appError("MyError", { details: { ... } });

AppError

Error class that can be thrown from domain/application code.
class AppError extends Error {
  constructor(
    code: string,
    message: string,
    status: number,
    details?: Record<string, unknown>
  );
  
  code: string;
  status: number;
  details?: Record<string, unknown>;
}

isAppError(err: unknown): err is AppError

Type guard to check if an error is an AppError.
try {
  await somethingRisky();
} catch (err) {
  if (isAppError(err)) {
    console.log(err.code, err.status);
  } else {
    console.error("Unknown error:", err);
  }
}

toErrorResponseBody(err: AppError): ErrorResponseBody

Converts an AppError to a standard error response body.
const responseBody = toErrorResponseBody(appError);
// { code: "MY_ERROR", message: "...", details?: { ... } }

createErrorResponseSchema(schemaBuilder: () => TSchema): TSchema

Helper for creating error response schemas using your preferred schema library.
import { z } from "zod";

const ErrorSchema = createErrorResponseSchema(() =>
  z.object({
    code: z.string(),
    message: z.string(),
    details: z.record(z.unknown()).optional(),
  })
);

httpErrors

A base catalog of common HTTP-related errors.
import { httpErrors } from "@contract-kit/errors/http";

// Use directly or extend
const errors = defineErrors({
  ...httpErrors,
  CustomError: { code: "CUSTOM", status: 400, message: "Custom error" },
});

HttpErrorCatalog

TypeScript type of the default HTTP error catalog.
import type { HttpErrorCatalog } from "@contract-kit/errors/http";

Best Practices

Error codes should be SCREAMING_SNAKE_CASE and clearly describe the problem: TODO_NOT_FOUND, INVALID_EMAIL_FORMAT, INSUFFICIENT_PERMISSIONS.
Add context in the details field to help with debugging: resource IDs, validation errors, or any relevant data.
Use appropriate HTTP status codes: 400 for client errors, 401 for authentication, 403 for authorization, 404 for not found, 409 for conflicts, 422 for validation errors, 500 for server errors.
Keep all error definitions in one place (e.g., app/errors.ts) for consistency and easy maintenance.
Start with the base httpErrors catalog and add your domain-specific errors to avoid duplicating common HTTP errors.

Testing Errors

Test that your use cases throw the correct errors:
import { getTodo } from "@/application/todos/get";
import { err } from "@/app/errors";
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);
      }
    }
  });
});

Next Steps