Skip to content

Error Handling

Philosophy: Thrown Errors, Not Result Types

Section titled “Philosophy: Thrown Errors, Not Result Types”

TypoKit uses thrown errors rather than Result<T, E> return types. This is a deliberate choice:

  1. Centralized handling — A single error middleware catches all errors and serializes them consistently, so individual handlers don’t need error-formatting boilerplate.
  2. Automatic context enrichment — The framework attaches traceId, sourceFile, schemaFile, and relatedTests to every error response automatically. With Result types, each call site would need to thread that context manually.
  3. Cleaner handler code — Handlers focus on the happy path. When something goes wrong, they call ctx.fail() and the framework handles the rest.
// TypoKit style — clean happy path
"GET /users/:id": async ({ params, ctx }) => {
const user = await userService.findById(params.id, ctx);
if (!user) return ctx.fail(404, "USER_NOT_FOUND", `User ${params.id} not found`);
return user;
}

All TypoKit errors extend AppError from @typokit/errors:

export class AppError extends Error {
constructor(
public readonly code: string,
public readonly status: number,
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "AppError";
}
toJSON(): ErrorResponse {
return {
error: {
code: this.code,
message: this.message,
...(this.details !== undefined ? { details: this.details } : {}),
},
};
}
}

Five built-in subclasses cover common HTTP error scenarios:

ClassStatusDefault CodeWhen to Use
NotFoundError404NOT_FOUNDResource doesn’t exist
ValidationError400VALIDATION_ERRORInvalid input data
UnauthorizedError401UNAUTHORIZEDMissing or invalid credentials
ForbiddenError403FORBIDDENAuthenticated but insufficient permissions
ConflictError409CONFLICTDuplicate resource or version conflict

You can throw these directly when you need custom codes:

import { NotFoundError, ConflictError } from "@typokit/errors";
// Custom code for more specific error identification
throw new NotFoundError("TEAM_NOT_FOUND", "Team does not exist");
// With additional details
throw new ConflictError(
"DUPLICATE_EMAIL",
"A user with this email already exists",
{ email: input.email }
);

Most of the time you won’t import error classes directly. Instead, use ctx.fail() which is available in every handler and middleware:

ctx.fail(status: number, code: string, message: string, details?: Record<string, unknown>): never

ctx.fail() is syntactic sugar that creates the appropriate AppError subclass and throws it. The never return type tells TypeScript that code after ctx.fail() is unreachable:

"POST /teams/:teamId/members": async ({ params, body, ctx }) => {
const team = await teamService.findById(params.teamId, ctx);
if (!team) return ctx.fail(404, "TEAM_NOT_FOUND", `Team ${params.teamId} not found`);
const isMember = await teamService.isMember(team.id, body.userId, ctx);
if (isMember) return ctx.fail(409, "ALREADY_MEMBER", "User is already a team member");
if (team.memberCount >= team.maxMembers) {
return ctx.fail(403, "TEAM_FULL", "Team has reached its member limit", {
current: team.memberCount,
max: team.maxMembers,
});
}
return await teamService.addMember(team.id, body.userId, ctx);
}

TypoKit includes createErrorMiddleware() that wraps every request in a try/catch and automatically serializes errors into consistent JSON responses:

import { createErrorMiddleware } from "@typokit/core";
const errorMiddleware = createErrorMiddleware({
isDev: process.env.NODE_ENV === "development",
});

The middleware handles three categories of errors:

Any AppError (including those thrown by ctx.fail()) is serialized using its toJSON() method, with the request’s traceId attached:

{
"error": {
"code": "USER_NOT_FOUND",
"message": "User abc-123 not found",
"traceId": "req-550e8400-e29b"
}
}

When Typia’s runtime validation rejects input, the middleware extracts field-level details automatically:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"fields": [
{
"path": "$.email",
"expected": "string & format:email",
"value": 42
}
]
},
"traceId": "req-550e8400-e29b"
}
}

Any unhandled error that isn’t an AppError or Typia validation error triggers different behavior depending on the environment.

The error middleware adjusts its response based on the isDev option (defaults to NODE_ENV === "development"):

BehaviorDevelopmentProduction
AppError responsesFull code, message, detailsFull code, message, details
Unknown error messageActual err.message exposedGeneric "Internal Server Error"
Stack traceIncluded in details.stackOmitted — never sent to client
Error nameIncluded in details.nameOmitted
Server-side loggingConsole outputFull details sent to logging pipeline

Development response for an unknown error:

{
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "Cannot read properties of undefined (reading 'id')",
"details": {
"stack": "TypeError: Cannot read properties of undefined...",
"name": "TypeError"
},
"traceId": "req-550e8400-e29b"
}
}

Production response for the same error:

{
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "Internal Server Error",
"traceId": "req-550e8400-e29b"
}
}

TypoKit’s error responses are designed to be machine-readable. When the debug sidecar or observability pipeline is active, errors include extended context that AI agents can use to diagnose and fix issues:

{
"error": "VALIDATION_FAILED",
"traceId": "abc-123",
"route": "POST /users",
"phase": "request_validation",
"schema": "CreateUserInput",
"server": "fastify",
"failures": [
{
"path": "$.email",
"expected": "string & format:email",
"received": "number (42)",
"suggestion": "Change the value at $.email to a valid email string"
}
],
"sourceFile": "src/routes/users/handlers.ts",
"schemaFile": "src/types.ts:8",
"relatedTests": ["__generated__/users.contract.test.ts:15"]
}

Key fields in the extended error format:

FieldPurpose
traceIdCorrelates the error across logs, metrics, and traces
routeThe route pattern that triggered the error
phaseWhere in the pipeline the error occurred (request_validation, handler, response_serialization, middleware)
failuresArray of specific validation failures with paths and suggestions
sourceFileThe handler file where the error originated
schemaFileThe TypeScript interface file and line number that defines the expected type
relatedTestsAuto-generated contract test files relevant to this error
  1. Use ctx.fail() for expected errors — Don’t import error classes unless you need a custom subclass.
  2. Let validation errors propagate — Typia validation errors are caught and formatted automatically. Don’t try/catch them in handlers.
  3. Use specific error codesUSER_NOT_FOUND is better than NOT_FOUND. Specific codes help clients and AI agents differentiate between error cases.
  4. Include helpful details — The details parameter on ctx.fail() accepts any object. Include data that helps the caller understand what went wrong.
  5. Don’t catch-and-rethrow — The error middleware handles all serialization. Catch-and-rethrow patterns lose the original stack trace.