API and Interface Design
Guides stable API and interface design. Use when designing APIs, module boundaries, or any public interface.
Guides stable API and interface design. Use when designing APIs, module boundaries, or any public interface.
Design stable, well-documented interfaces that are hard to misuse.
With a sufficient number of users of an API, all observable behaviors of your system will be depended on by somebody.
Every public behavior — including undocumented quirks — becomes a de facto contract once users depend on it.
Design for a world where only one version exists at a time — extend rather than fork.
Define the interface before implementing it:
interface TaskAPI {
createTask(input: CreateTaskInput): Promise<Task>;
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
getTask(id: string): Promise<Task>;
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
deleteTask(id: string): Promise<void>;
}
interface APIError {
error: {
code: string; // Machine-readable: "VALIDATION_ERROR"
message: string; // Human-readable: "Email is required"
details?: unknown;
};
}
// Status code mapping
// 400 → Client sent invalid data
// 401 → Not authenticated
// 403 → Authenticated but not authorized
// 404 → Resource not found
// 422 → Validation failed
// 500 → Server error (never expose internal details)
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid task data',
details: result.error.flatten(),
},
});
}
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
// Good: Add optional fields
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // Added later, optional
}
// Bad: Change existing field types
interface CreateTaskInput {
title: string;
priority: number; // Changed from string — breaks consumers
}
| Pattern | Convention | Example |
|---------|-----------|---------|
| REST endpoints | Plural nouns, no verbs | GET /api/tasks |
| Query params | camelCase | ?sortBy=createdAt |
| Response fields | camelCase | { createdAt, updatedAt } |
| Boolean fields | is/has/can prefix | isComplete |
| Enum values | UPPER_SNAKE | "IN_PROGRESS" |
GET /api/tasks → List tasks
POST /api/tasks → Create a task
GET /api/tasks/:id → Get a single task
PATCH /api/tasks/:id → Update a task (partial)
DELETE /api/tasks/:id → Delete a task
// Request
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Response
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
// Only title changes, everything else preserved
PATCH /api/tasks/123
{ "title": "Updated title" }
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Pending';
case 'in_progress': return `In progress (${status.assignee})`;
case 'completed': return `Done on ${status.completedAt}`;
case 'cancelled': return `Cancelled: ${status.reason}`;
}
}
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
function getTask(id: TaskId): Promise<Task> { ... }
After designing an API: