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:
- Compile-time safety: Catch type mismatches, missing properties, and incorrect function signatures before your code ever runs
- Self-documenting code: Type annotations serve as living documentation for your API's contracts
- Enhanced IDE experience: IntelliSense, autocompletion, and inline documentation dramatically improve developer productivity
- Refactoring confidence: Rename a property or change a type signature and TypeScript immediately flags every affected location
- Gradual adoption: You can introduce types incrementally, typing only the parts of your application that benefit most
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:
Koa.ParameterizedContext<StateT, CustomT>— The full context type, generic over state and custom propertiesKoa.Request— The typed request objectKoa.Response— The typed response objectKoa.Middleware<StateT, CustomT>— The middleware function signature
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