← Back to DevBytes

Koa TypeScript: Strongly Typed Applications

Introduction to Koa with TypeScript

Koa is a lightweight, expressive middleware framework for Node.js created by the team behind Express. It leverages async functions and a clean middleware stack to handle HTTP requests and responses. When paired with TypeScript, Koa becomes an exceptionally powerful tool for building strongly typed, maintainable, and self-documenting server-side applications. This tutorial will guide you through building strongly typed Koa applications from the ground up, covering project setup, typing strategies, middleware patterns, and production-ready best practices.

What is Koa and Why TypeScript Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Koa's architecture is minimal by design. It provides a context object (ctx) that encapsulates the request and response, and a middleware pipeline that processes requests sequentially using async/await. Unlike Express, Koa does not bundle any middleware out of the box, giving you complete control over your application's composition.

Adding TypeScript brings several critical benefits to Koa applications:

Setting Up a Koa TypeScript Project

Let's start by scaffolding a new Koa TypeScript project from scratch. This setup uses modern tooling for optimal development experience.

Step 1: Initialize the Project

mkdir koa-ts-app
cd koa-ts-app
npm init -y

Step 2: Install Core Dependencies

npm install koa koa-router @koa/cors
npm install --save-dev typescript @types/node @types/koa @types/koa__cors @types/koa-router ts-node nodemon

Step 3: Configure TypeScript

Create a tsconfig.json file in the project root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "moduleResolution": "node",
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

The strict: true flag enables all strict type-checking options, which is essential for building truly strongly typed applications. It includes noImplicitAny, strictNullChecks, strictFunctionTypes, and several other checks that prevent common type-related bugs.

Step 4: Add Development Scripts to package.json

"scripts": {
  "build": "tsc",
  "dev": "nodemon --exec ts-node src/index.ts",
  "start": "node dist/index.js",
  "type-check": "tsc --noEmit"
}

Step 5: Create the Project Structure

mkdir src
mkdir src/middleware
mkdir src/routes
mkdir src/types
touch src/index.ts

Typing the Koa Context Object

The Koa context is the heart of every Koa application. It combines the request and response objects into a single unified interface, and it's also where you attach custom properties throughout your middleware pipeline. Properly typing the context is the foundation of a strongly typed Koa application.

Understanding the Base Types

Koa provides its own type definitions through @types/koa. The core types you'll work with are:

Extending the Context with Custom Properties

A common pattern is attaching user information, database connections, or service instances to the context. Here's how to do it in a type-safe way:

// src/types/context.ts
import { type Context as KoaContext } from 'koa';

// Define your custom context extensions
export interface CustomAppContext {
  user?: {
    id: string;
    email: string;
    role: 'admin' | 'user' | 'guest';
  };
  db?: DatabaseConnection;
  logger?: Logger;
  requestId?: string;
}

// Create a typed context alias for use throughout your app
export type AppContext = KoaContext & CustomAppContext;

// For parameterized contexts (e.g., in routers)
export interface AppState {
  // Shared state properties across middleware
  startTime: number;
  traceId: string;
}

export type AppParameterizedContext = KoaContext & CustomAppContext & { params: Record };

Creating Strongly Typed Middleware

Middleware in Koa is an async function that receives the context and a next callback. Typing middleware correctly ensures that your context extensions propagate through the entire middleware chain.

Basic Typed Middleware

// src/middleware/request-id.ts
import { type Middleware } from 'koa';
import { v4 as uuidv4 } from 'uuid';
import type { AppContext } from '../types/context';

// Explicitly typed middleware using the Koa.Middleware generic
export const requestIdMiddleware: Middleware = async (ctx, next) => {
  // Cast to your custom context type for access to custom properties
  const appCtx = ctx as AppContext;
  appCtx.requestId = uuidv4();
  ctx.set('X-Request-Id', appCtx.requestId);
  await next();
};

Middleware with Typed State

When your middleware needs to pass typed state to downstream middleware, use the generic parameters:

// src/middleware/timing.ts
import { type Middleware } from 'koa';

interface TimingState {
  timing: {
    start: number;
    middleware: Record;
  };
}

// Type the middleware with custom state
export const timingMiddleware: Middleware = async (ctx, next) => {
  const timingState = ctx.state as TimingState;
  timingState.timing = {
    start: Date.now(),
    middleware: {},
  };

  const start = Date.now();
  await next();
  const ms = Date.now() - start;

  timingState.timing.middleware['timingMiddleware'] = ms;
  console.log(`Request processed in ${ms}ms`);
};

Middleware Factory Pattern with Types

Middleware factories that accept configuration benefit enormously from TypeScript:

// src/middleware/auth.ts
import { type Middleware } from 'koa';
import type { AppContext } from '../types/context';

interface AuthOptions {
  requiredRoles?: ('admin' | 'user' | 'guest')[];
  jwtSecret: string;
}

interface AuthError {
  status: number;
  message: string;
  code: string;
}

export function createAuthMiddleware(options: AuthOptions): Middleware {
  return async (ctx, next) => {
    const appCtx = ctx as AppContext;

    try {
      const token = ctx.headers.authorization?.replace('Bearer ', '');

      if (!token) {
        const error: AuthError = {
          status: 401,
          message: 'Authentication token is required',
          code: 'AUTH_REQUIRED',
        };
        ctx.status = error.status;
        ctx.body = { error };
        return;
      }

      // Verify and decode token (implementation omitted for brevity)
      const decoded = verifyToken(token, options.jwtSecret);

      appCtx.user = {
        id: decoded.sub,
        email: decoded.email,
        role: decoded.role,
      };

      // Role-based access control
      if (options.requiredRoles && !options.requiredRoles.includes(appCtx.user.role)) {
        const error: AuthError = {
          status: 403,
          message: 'Insufficient permissions',
          code: 'FORBIDDEN',
        };
        ctx.status = error.status;
        ctx.body = { error };
        return;
      }

      await next();
    } catch (err) {
      const error: AuthError = {
        status: 401,
        message: 'Invalid or expired token',
        code: 'AUTH_INVALID',
      };
      ctx.status = error.status;
      ctx.body = { error };
    }
  };
}

function verifyToken(token: string, secret: string): { sub: string; email: string; role: 'admin' | 'user' | 'guest' } {
  // JWT verification logic here
  // This is a placeholder — use a real JWT library like jsonwebtoken
  return { sub: 'user-123', email: 'user@example.com', role: 'user' };
}

Typed Routing with koa-router

The koa-router library is the de facto routing solution for Koa. With TypeScript, you can create routers that enforce parameter types and response shapes at compile time.

Creating a Typed Router

// src/routes/users.ts
import Router from 'koa-router';
import type { AppContext, AppState } from '../types/context';

// Define typed request body interfaces
interface CreateUserBody {
  name: string;
  email: string;
  password: string;
}

interface UpdateUserBody {
  name?: string;
  email?: string;
}

// Define typed response interfaces
interface UserResponse {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: unknown;
  };
}

// Create a router with typed state and context
const router = new Router();

// GET /users — list all users
router.get('/users', async (ctx) => {
  const users: UserResponse[] = [
    { id: '1', name: 'Alice', email: 'alice@example.com', createdAt: new Date() },
    { id: '2', name: 'Bob', email: 'bob@example.com', createdAt: new Date() },
  ];

  ctx.status = 200;
  ctx.body = {
    data: users,
    total: users.length,
  };
});

// GET /users/:id — get a single user by ID
router.get('/users/:id', async (ctx) => {
  const userId: string = ctx.params.id;

  if (!userId) {
    const errorBody: ErrorResponse = {
      error: {
        code: 'INVALID_PARAM',
        message: 'User ID is required',
      },
    };
    ctx.status = 400;
    ctx.body = errorBody;
    return;
  }

  const user: UserResponse = {
    id: userId,
    name: 'Alice',
    email: 'alice@example.com',
    createdAt: new Date(),
  };

  ctx.status = 200;
  ctx.body = { data: user };
});

// POST /users — create a new user
router.post('/users', async (ctx) => {
  const body = ctx.request.body as CreateUserBody;

  // Validate the request body
  if (!body.name || !body.email || !body.password) {
    const errorBody: ErrorResponse = {
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Name, email, and password are required',
        details: { missing: ['name', 'email', 'password'].filter(f => !body[f as keyof CreateUserBody]) },
      },
    };
    ctx.status = 400;
    ctx.body = errorBody;
    return;
  }

  // Simulate user creation
  const newUser: UserResponse = {
    id: '3',
    name: body.name,
    email: body.email,
    createdAt: new Date(),
  };

  ctx.status = 201;
  ctx.body = { data: newUser };
});

// PUT /users/:id — update a user
router.put('/users/:id', async (ctx) => {
  const userId: string = ctx.params.id;
  const body = ctx.request.body as UpdateUserBody;

  const updatedUser: UserResponse = {
    id: userId,
    name: body.name ?? 'Alice',
    email: body.email ?? 'alice@example.com',
    createdAt: new Date(),
  };

  ctx.status = 200;
  ctx.body = { data: updatedUser };
});

// DELETE /users/:id — remove a user
router.delete('/users/:id', async (ctx) => {
  const userId: string = ctx.params.id;

  ctx.status = 204;
  ctx.body = null;
});

export default router;

Using the Typed Router in Your Application

// src/index.ts
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import cors from '@koa/cors';
import { requestIdMiddleware } from './middleware/request-id';
import { timingMiddleware } from './middleware/timing';
import { createAuthMiddleware } from './middleware/auth';
import usersRouter from './routes/users';

const app = new Koa();

// Global middleware
app.use(cors({ origin: '*' }));
app.use(bodyParser());
app.use(requestIdMiddleware);
app.use(timingMiddleware);

// Protected routes with authentication
const authMiddleware = createAuthMiddleware({
  jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
  requiredRoles: ['admin', 'user'],
});

// Apply auth middleware only to specific routes
app.use(authMiddleware);

// Register routers
app.use(usersRouter.routes());
app.use(usersRouter.allowedMethods());

// Error handling middleware
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    const error = err as Error;
    console.error(`[Error] ${error.message}`, error.stack);
    ctx.status = 500;
    ctx.body = {
      error: {
        code: 'INTERNAL_ERROR',
        message: 'An unexpected error occurred',
      },
    };
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Advanced Typing Patterns

Discriminated Unions for Response Types

Use discriminated unions to model complex response shapes with type-safe narrowing:

// src/types/responses.ts

// Success response wrapper
interface SuccessResponse {
  status: 'success';
  data: T;
  meta?: {
    total: number;
    page: number;
    pageSize: number;
  };
}

// Error response wrapper
interface ErrorResponse {
  status: 'error';
  error: {
    code: string;
    message: string;
    details?: unknown;
  };
}

// Discriminated union
export type ApiResponse = SuccessResponse | ErrorResponse;

// Type guard functions
export function isSuccess(response: ApiResponse): response is SuccessResponse {
  return response.status === 'success';
}

export function isError(response: ApiResponse): response is ErrorResponse {
  return response.status === 'error';
}

// Usage in a route handler
interface UserProfile {
  id: string;
  name: string;
  email: string;
  avatarUrl: string;
}

async function getUserProfile(ctx: AppContext): Promise {
  const response: ApiResponse = {
    status: 'success',
    data: {
      id: '1',
      name: 'Alice',
      email: 'alice@example.com',
      avatarUrl: 'https://example.com/avatar.jpg',
    },
  };

  ctx.body = response;
}

// On the client side, you'd narrow the type:
function handleResponse(response: ApiResponse) {
  if (isSuccess(response)) {
    // TypeScript knows response.data is UserProfile here
    console.log(response.data.name);
  } else {
    // TypeScript knows response.error exists here
    console.error(response.error.message);
  }
}

Typed Middleware Composition

Compose multiple middleware functions while preserving types through the chain:

// src/middleware/composed.ts
import { type Middleware } from 'koa';

interface ValidationContext {
  validatedBody: unknown;
  validationErrors?: string[];
}

interface RateLimitContext {
  rateLimit: {
    remaining: number;
    reset: number;
  };
}

// Each middleware adds typed properties to the context
const validateBody: Middleware = async (ctx, next) => {
  const validationCtx = ctx.state as ValidationContext;
  validationCtx.validatedBody = ctx.request.body;
  validationCtx.validationErrors = [];
  await next();
};

const checkRateLimit: Middleware = async (ctx, next) => {
  const rateLimitCtx = ctx.state as RateLimitContext;
  rateLimitCtx.rateLimit = {
    remaining: 100,
    reset: Date.now() + 3600000,
  };
  await next();
};

// Compose middleware — state types accumulate
export const composedMiddleware: Middleware = async (ctx, next) => {
  await validateBody(ctx, async () => {
    await checkRateLimit(ctx, async () => {
      // Both ValidationContext and RateLimitContext are available here
      const combinedState = ctx.state as ValidationContext & RateLimitContext;
      console.log('Validated:', combinedState.validatedBody);
      console.log('Rate limit remaining:', combinedState.rateLimit.remaining);
      await next();
    });
  });
};

Generic Service Layer with Typed Context

// src/services/user-service.ts
import type { AppContext } from '../types/context';

interface PaginationParams {
  page: number;
  pageSize: number;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}

interface PaginatedResult {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  createdAt: Date;
  updatedAt: Date;
}

export class UserService {
  // Generic method with typed parameters and return type
  async findMany(params: PaginationParams): Promise> {
    const { page, pageSize, sortBy = 'createdAt', sortOrder = 'desc' } = params;

    // Database query simulation
    const users: User[] = [
      { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin', createdAt: new Date(), updatedAt: new Date() },
      { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user', createdAt: new Date(), updatedAt: new Date() },
    ];

    const filteredUsers = users.slice((page - 1) * pageSize, page * pageSize);

    return {
      items: filteredUsers,
      total: users.length,
      page,
      pageSize,
      totalPages: Math.ceil(users.length / pageSize),
    };
  }

  async findById(id: string): Promise {
    // Database lookup simulation
    if (id === '1') {
      return { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin', createdAt: new Date(), updatedAt: new Date() };
    }
    return null;
  }

  async create(data: Omit): Promise {
    return {
      id: Math.random().toString(36).substring(7),
      ...data,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
  }

  async update(id: string, data: Partial>): Promise {
    const existing = await this.findById(id);
    if (!existing) return null;

    return {
      ...existing,
      ...data,
      updatedAt: new Date(),
    };
  }

  async delete(id: string): Promise {
    const existing = await this.findById(id);
    return existing !== null;
  }
}

// Using the service in a route handler
export async function listUsersHandler(ctx: AppContext): Promise {
  const userService = new UserService();

  const paginationParams: PaginationParams = {
    page: Number(ctx.query.page) || 1,
    pageSize: Number(ctx.query.pageSize) || 10,
    sortBy: ctx.query.sortBy as string | undefined,
    sortOrder: ctx.query.sortOrder as 'asc' | 'desc' | undefined,
  };

  const result = await userService.findMany(paginationParams);

  ctx.status = 200;
  ctx.body = {
    status: 'success',
    data: result.items,
    meta: {
      total: result.total,
      page: result.page,
      pageSize: result.pageSize,
      totalPages: result.totalPages,
    },
  };
}

Error Handling with Strong Types

Typed error handling ensures that your application fails predictably and that error responses follow a consistent contract.

// src/errors/app-error.ts

export enum ErrorCode {
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  NOT_FOUND = 'NOT_FOUND',
  UNAUTHORIZED = 'UNAUTHORIZED',
  FORBIDDEN = 'FORBIDDEN',
  CONFLICT = 'CONFLICT',
  INTERNAL_ERROR = 'INTERNAL_ERROR',
  RATE_LIMITED = 'RATE_LIMITED',
}

export class AppError extends Error {
  public readonly status: number;
  public readonly code: ErrorCode;
  public readonly details?: unknown;
  public readonly timestamp: string;

  constructor(status: number, code: ErrorCode, message: string, details?: unknown) {
    super(message);
    this.status = status;
    this.code = code;
    this.details = details;
    this.timestamp = new Date().toISOString();
    this.name = 'AppError';

    // Capture stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, AppError);
    }
  }

  // Factory methods for common error types
  static notFound(message: string = 'Resource not found', details?: unknown): AppError {
    return new AppError(404, ErrorCode.NOT_FOUND, message, details);
  }

  static validationError(message: string, details?: unknown): AppError {
    return new AppError(400, ErrorCode.VALIDATION_ERROR, message, details);
  }

  static unauthorized(message: string = 'Authentication required'): AppError {
    return new AppError(401, ErrorCode.UNAUTHORIZED, message);
  }

  static forbidden(message: string = 'Insufficient permissions'): AppError {
    return new AppError(403, ErrorCode.FORBIDDEN, message);
  }

  static conflict(message: string, details?: unknown): AppError {
    return new AppError(409, ErrorCode.CONFLICT, message, details);
  }

  // Serialize for API responses
  toJSON(): Record {
    return {
      error: {
        code: this.code,
        message: this.message,
        details: this.details,
        timestamp: this.timestamp,
      },
    };
  }
}

// src/middleware/error-handler.ts
import { type Middleware } from 'koa';
import { AppError, ErrorCode } from '../errors/app-error';
import type { AppContext } from '../types/context';

export const errorHandlerMiddleware: Middleware = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    const appCtx = ctx as AppContext;

    if (err instanceof AppError) {
      ctx.status = err.status;
      ctx.body = err.toJSON();

      if (appCtx.logger) {
        appCtx.logger.warn(`[AppError] ${err.code}: ${err.message}`, {
          status: err.status,
          details: err.details,
          requestId: appCtx.requestId,
        });
      }
    } else {
      // Handle unexpected errors
      const error = err as Error;
      console.error(`[Unexpected Error] ${error.message}`, error.stack);

      const internalError = new AppError(
        500,
        ErrorCode.INTERNAL_ERROR,
        'An unexpected error occurred',
        process.env.NODE_ENV === 'development' ? error.message : undefined
      );

      ctx.status = internalError.status;
      ctx.body = internalError.toJSON();
    }
  }
};

Type-Safe Request Validation

Validating incoming request data with TypeScript ensures that your route handlers work with correctly shaped data. Here's a pattern using Zod for runtime validation that produces TypeScript types:

// src/validation/schemas.ts
import { z } from 'zod';

// Define Zod schemas — these produce both runtime validation AND TypeScript types
export const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  password: z.string().min(8).max(128),
  role: z.enum(['admin', 'user', 'guest']).optional().default('user'),
});

export const updateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  email: z.string().email().optional(),
  role: z.enum(['admin', 'user', 'guest']).optional(),
});

export const paginationSchema = z.object({
  page: z.coerce.number().int().positive().optional().default(1),
  pageSize: z.coerce.number().int().min(1).max(100).optional().default(10),
  sortBy: z.string().optional(),
  sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
});

// Extract TypeScript types from schemas
export type CreateUserInput = z.infer;
export type UpdateUserInput = z.infer;
export type PaginationInput = z.infer;

// src/middleware/validate.ts
import { type Middleware } from 'koa';
import { type ZodSchema, ZodError } from 'zod';
import { AppError } from '../errors/app-error';

type ValidationTarget = 'body' | 'query' | 'params';

interface ValidateOptions {
  schema: ZodSchema;
  target: ValidationTarget;
}

export function createValidationMiddleware(options: ValidateOptions): Middleware {
  return async (ctx, next) => {
    try {
      const data = ctx.request[options.target];
      const validated = options.schema.parse(data);

      // Replace the original data with validated data
      ctx.request[options.target] = validated;

      await next();
    } catch (err) {
      if (err instanceof ZodError) {
        const details = err.errors.map((e) => ({
          path: e.path.join('.'),
          message: e.message,
          code: e.code,
        }));

        throw AppError.validationError('Request validation failed', details);
      }
      throw err;
    }
  };
}

// Usage in routes
import { createValidationMiddleware } from '../middleware/validate';
import { createUserSchema, paginationSchema } from '../validation/schemas';

const validateCreateUser = createValidationMiddleware({
  schema: createUserSchema,
  target: 'body',
});

const validatePagination = createValidationMiddleware({
  schema: paginationSchema,
  target: 'query',
});

router.post('/users', validateCreateUser, async (ctx) => {
  // ctx.request.body is now typed as CreateUserInput
  const body = ctx.request.body as CreateUserInput;

  // TypeScript enforces that body has name, email, password, and optional role
  const newUser = await userService.create({
    name: body.name,
    email: body.email,
    role: body.role ?? 'user',
  });

  ctx.status = 201;
  ctx.body = { status: 'success', data: newUser };
});

Best Practices for Koa TypeScript Applications

1. Use Strict TypeScript Configuration

Always enable strict: true in your tsconfig. This single flag activates seven separate strict checks that collectively prevent the most common type-related bugs. If you're migrating an existing JavaScript codebase, you can enable strict checks incrementally, but new Koa TypeScript projects should start with full strict mode.

2. Prefer Interface Over Type for Public Contracts

Use interface for public API contracts (request bodies, response shapes, service method signatures) and reserve type for unions, intersections, and utility types. Interfaces are extendable and produce clearer error messages.

// Prefer this
interface UserResponse {
  id: string;
  name: string;
  email: string;
}

// Over this for simple object shapes
type UserResponse = {
  id: string;
  name: string;
  email: string;
};

3. Create a Dedicated Types Directory

Centralize shared type definitions in a src/types directory. This prevents circular dependencies and makes types easily discoverable. Use barrel exports (index.ts files) to simplify imports.

// src/types/index.ts
export type { AppContext, CustomAppContext, AppState } from './context';
export type { ApiResponse, SuccessResponse, ErrorResponse } from './responses';
export type { PaginationParams, PaginatedResult } from './common';

4. Use Discriminated Unions for Response Types

Model API responses as discriminated unions with a status field. This enables exhaustive type checking on the client side and ensures consistent response structures across your API.

5. Type Context Extensions Through Declaration Merging

For globally available context properties, use TypeScript's declaration merging to extend the Koa context type itself:

// src/types/koa-extensions.d.ts
import 'koa';

declare module 'koa' {
  interface BaseContext {
    requestId: string;
    user?: {
      id: string;
      email: string;
      role: 'admin' | 'user' | 'guest';
    };
    logger: {
      info: (message: string, meta?: Record) => void;
      warn: (message: string, meta?: Record) => void;
      error: (message: string, meta?: Record) => void;
    };
  }
}

This approach eliminates the need to cast ctx throughout your application.

6. Implement Typed Error Boundaries

Create custom error classes with typed properties and use them consistently. This makes error handling predictable and allows middleware to respond with structured error objects.

7. Combine Runtime Validation with Compile-Time Types

Use libraries like Zod, Yup, or io-ts to validate incoming data at runtime while deriving TypeScript types from the validation schemas. This bridges the gap between runtime uncertainty and compile-time safety.

8. Leverage Generic Middleware Factories

Create middleware factories that accept typed configuration objects. This pattern is infinitely reusable and keeps your middleware composable:

interface LoggerOptions {
  level: 'debug' | 'info' | 'warn' | 'error';
  format?: 'json' | 'text';
  destination?: 'console' | 'file';
}

function createLogger(options: LoggerOptions): Middleware {
  return async (ctx, next) => {
    // Implementation using typed options
    await next();
  };
}

9. Use Readonly Types for Immutable Data

Mark properties that shouldn't be mutated as readonly to catch accidental modifications:

interface ImmutableUser {
  readonly id: string;
  readonly createdAt: Date;
  name: string; // mutable
  email: string; // mutable
  readonly role: 'admin' | 'user' | 'guest';
}

10. Document Type Signatures with JSDoc

Even with TypeScript's static typing, JSDoc annotations improve the developer experience by providing descriptions that appear in IDE tooltips:

/**
 * Creates a new user in the system.
 * @param data - The user creation payload
 * @returns The newly created user with generated ID and timestamps
 * @throws {AppError} If validation fails or email already exists
 */
async create(data: CreateUserInput): Promise {
  // Implementation
}

Testing Typed Koa Applications

TypeScript also enhances your testing workflow. Here's how to write typed tests for your Koa application:

// src/__tests__/users.test.ts
import { createServer } from 'node:http';
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import request from 'supertest';
import usersRouter from '../routes/users';
import { errorHandlerMiddleware } from '../middleware/error-handler';
import type { UserResponse, ApiResponse } from '../types';

describe('Users API', () => {
  let app: Koa;
  let server: ReturnType;

  beforeAll(() => {
    app = new Koa();
    app.use(errorHandlerMiddleware);
    app.use(bodyParser());
    app.use(usersRouter.routes());
    app.use(usersRouter.all

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles