Development
API and Interface Design
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.
When to Use
- Designing new API endpoints
- Defining module boundaries or contracts between teams
- Creating component prop interfaces
- Establishing database schema that informs API shape
- Changing existing public interfaces
Core Principles
Hyrum's Law
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.
The One-Version Rule
Design for a world where only one version exists at a time — extend rather than fork.
1. Contract First
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>;
}
2. Consistent Error Semantics
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)
3. Validate at Boundaries
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);
});
4. Prefer Addition Over Modification
// 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
}
5. Predictable Naming
| 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" |
REST API Patterns
Resource Design
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
Pagination
// Request
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Response
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
Partial Updates (PATCH)
// Only title changes, everything else preserved
PATCH /api/tasks/123
{ "title": "Updated title" }
TypeScript Interface Patterns
Discriminated Unions for Variants
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}`;
}
}
Branded Types for IDs
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
function getTask(id: TaskId): Promise<Task> { ... }
Verification
After designing an API:
- Every endpoint has typed input and output schemas
- Error responses follow a single consistent format
- Validation happens at system boundaries only
- List endpoints support pagination
- New fields are additive and optional (backward compatible)
- Naming follows consistent conventions across all endpoints