atelier-typescript-api-design

Best practices for designing REST APIs with consistent structure, error handling, and resource patterns.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "atelier-typescript-api-design" with this command: npx skills add martinffx/claude-code-atelier/martinffx-claude-code-atelier-atelier-typescript-api-design

API Design Patterns

Best practices for designing REST APIs with consistent structure, error handling, and resource patterns.

Additional References

  • references/error-responses.md - Detailed error handling examples

Resource Naming

Use consistent, predictable URL patterns:

Collection resources (plural nouns)

GET /api/v1/users # List users POST /api/v1/users # Create user GET /api/v1/users/:id # Get user PUT /api/v1/users/:id # Update user (full) PATCH /api/v1/users/:id # Update user (partial) DELETE /api/v1/users/:id # Delete user

Nested resources

GET /api/v1/users/:userId/posts # List user's posts POST /api/v1/users/:userId/posts # Create post for user GET /api/v1/users/:userId/posts/:postId # Get specific post

Actions (use verbs sparingly)

POST /api/v1/users/:id/activate # Activate user POST /api/v1/posts/:id/publish # Publish post POST /api/v1/invoices/:id/send # Send invoice

Guidelines

  • Use plural nouns for collections (/users , not /user )

  • Use lowercase with hyphens for multi-word resources (/ledger-accounts )

  • Avoid deep nesting (max 2 levels: /users/:id/posts/:id )

  • Use query parameters for filtering, sorting, pagination

  • Use verbs only for actions that don't fit CRUD (activate, publish, send)

API Versioning

Version APIs in the URL path:

/api/v1/users /api/v2/users

Not in headers (harder to test/debug)

Not in query params (breaks caching)

Version Strategy

// v1/routes.ts export async function v1Routes(app: FastifyInstance) { app.get('/users', getUsersV1) app.post('/users', createUserV1) }

// v2/routes.ts export async function v2Routes(app: FastifyInstance) { app.get('/users', getUsersV2) // Breaking change in response structure app.post('/users', createUserV2) }

// server.ts app.register(v1Routes, { prefix: '/api/v1' }) app.register(v2Routes, { prefix: '/api/v2' })

RFC 7807 Problem Details

Standardized error response format:

interface ProblemDetail { type: string // Error type identifier status: number // HTTP status code title: string // Short, human-readable summary detail: string // Specific explanation for this occurrence instance: string // URI reference to specific occurrence traceId: string // Request trace ID for debugging }

// Example error response { "type": "NOT_FOUND", "status": 404, "title": "Not Found", "detail": "User with ID usr_01h455vb4pex5vsknk084sn02q not found", "instance": "/api/v1/users/usr_01h455vb4pex5vsknk084sn02q", "traceId": "req_abc123xyz" }

Error Types

// Domain error base class abstract class AppError extends Error { abstract readonly status: number abstract readonly type: string

constructor(message: string, public readonly context?: ErrorContext) { super(message) }

toResponse(instance: string, traceId: string): ProblemDetail { return { type: this.type, status: this.status, title: this.name, detail: this.message, instance, traceId, ...this.context, } } }

// Specific error types class NotFoundError extends AppError { readonly status = 404 readonly type = 'NOT_FOUND' }

class ConflictError extends AppError { readonly status = 409 readonly type = 'CONFLICT'

constructor( message: string, public readonly retryable: boolean = false, context?: ErrorContext ) { super(message, context) } }

class ServiceUnavailableError extends AppError { readonly status = 503 readonly type = 'SERVICE_UNAVAILABLE'

constructor( message: string, public readonly retryable: boolean = true, context?: ErrorContext ) { super(message, context) } }

See references/error-responses.md for complete examples.

Pagination (Cursor-Based)

Use cursor-based pagination for large datasets:

// Request GET /api/v1/posts?limit=20&cursor=pst_01h455vb4pex5vsknk084sn02q

// Response { "items": [ { "id": "pst_01h455w3x8k5z9y7q1m0n2b3c4", ... }, { "id": "pst_01h455x2y9l6a0z8r2n1o3c5d6", ... } ], "nextCursor": "pst_01h455z1a0m7b8y9s3o2p4d6e7", "hasMore": true }

Implementation

interface PaginatedRequest { limit?: number // Max items to return (default 20, max 100) cursor?: string // Cursor for next page (opaque to client) }

interface PaginatedResponse<T> { items: T[] nextCursor?: string hasMore: boolean }

async function listPosts(req: PaginatedRequest): Promise<PaginatedResponse<Post>> { const limit = Math.min(req.limit ?? 20, 100) const queryLimit = limit + 1 // Fetch one extra to check hasMore

const posts = await db.query.posts.findMany({ where: req.cursor ? gt(posts.id, req.cursor) : undefined, orderBy: desc(posts.createdAt), limit: queryLimit, })

const hasMore = posts.length > limit const items = posts.slice(0, limit) const nextCursor = hasMore ? items[items.length - 1].id : undefined

return { items, nextCursor, hasMore } }

Why Cursor Over Offset

❌ Offset-based (/posts?offset=40&limit=20)

  • Unstable: Items can shift if new records inserted
  • Performance: DB must scan all previous rows
  • Inaccurate: Can miss or duplicate items

✅ Cursor-based (/posts?cursor=pst_xyz&limit=20)

  • Stable: Cursor points to specific item
  • Performant: DB uses index seek
  • Accurate: No gaps or duplicates

Filtering & Sorting

Use query parameters for filtering and sorting:

Filtering

GET /api/v1/users?status=active&role=admin GET /api/v1/posts?author=usr_abc&published=true

Sorting

GET /api/v1/posts?sort=-createdAt # Descending (- prefix) GET /api/v1/users?sort=name # Ascending

Combined

GET /api/v1/posts?author=usr_abc&status=published&sort=-createdAt&limit=20

Implementation

interface ListPostsQuery { author?: string status?: 'draft' | 'published' sort?: 'createdAt' | '-createdAt' | 'title' | '-title' limit?: number cursor?: string }

async function listPosts(query: ListPostsQuery): Promise<PaginatedResponse<Post>> { const conditions = []

if (query.author) { conditions.push(eq(posts.authorId, query.author)) } if (query.status) { conditions.push(eq(posts.status, query.status)) }

const orderByColumn = query.sort?.startsWith('-') ? query.sort.slice(1) : query.sort ?? 'createdAt' const orderByDirection = query.sort?.startsWith('-') ? desc : asc

return await db.query.posts.findMany({ where: conditions.length > 0 ? and(...conditions) : undefined, orderBy: orderByDirection(posts[orderByColumn]), limit: query.limit ?? 20, }) }

HTTP Status Codes

Use status codes consistently:

Success

200 OK # Successful GET, PUT, PATCH 201 Created # Successful POST (include Location header) 204 No Content # Successful DELETE, PUT with no response body

Client Errors

400 Bad Request # Invalid request body/parameters 401 Unauthorized # Missing or invalid authentication 403 Forbidden # Valid auth, but lacks permission 404 Not Found # Resource doesn't exist 409 Conflict # Resource already exists, optimistic lock failure 422 Unprocessable # Validation error (semantic) 429 Too Many Requests # Rate limit exceeded

Server Errors

500 Internal Server Error # Unexpected error 503 Service Unavailable # Temporary unavailability, retry later

Response Envelope (When to Use)

Don't use envelopes for simple CRUD:

// ❌ Unnecessary wrapping GET /api/v1/users/123 { "success": true, "data": { "id": "123", "name": "Alice" } }

// ✅ Return resource directly GET /api/v1/users/123 { "id": "123", "name": "Alice" }

Use envelopes for pagination:

// ✅ Envelope needed for metadata GET /api/v1/users?limit=20 { "items": [...], "nextCursor": "usr_xyz", "hasMore": true }

Timestamps

Use ISO 8601 format for all timestamps:

{ "createdAt": "2024-01-15T14:30:00.000Z", // ISO 8601 UTC "updatedAt": "2024-01-16T09:15:30.123Z" }

// In entities toResponse(): UserResponse { return { ... createdAt: this.createdAt.toISOString(), // Date → ISO string updatedAt: this.updatedAt.toISOString(), } }

Idempotency

Use idempotency keys for safe retries:

// Request POST /api/v1/transactions Headers: Idempotency-Key: txn_abc123xyz Body: { "amount": 100, "from": "usr_123", "to": "usr_456" }

// Implementation async function createTransaction(rq: CreateTransactionRequest, idempotencyKey: string) { // Check if transaction with this key already exists const existing = await db.query.transactions.findFirst({ where: eq(transactions.idempotencyKey, idempotencyKey), })

if (existing) { return TransactionEntity.fromRecord(existing) // Return existing }

// Create new transaction const transaction = TransactionEntity.fromRequest(rq, idempotencyKey) return await transactionRepo.create(transaction) }

Guidelines

  • Plural nouns - Collections use plural resource names

  • Lowercase with hyphens - Multi-word resources like ledger-accounts

  • Version in URL - /api/v1/ , /api/v2/ for breaking changes

  • RFC 7807 errors - Standardized error response format

  • Cursor pagination - For large datasets (more stable than offset)

  • Query params - For filtering, sorting, pagination (not in path)

  • HTTP status codes - Use correct codes (200, 201, 204, 400, 404, 409, 500, 503)

  • ISO 8601 timestamps - Always use .toISOString() for dates

  • Idempotency keys - For non-idempotent operations (POST, PATCH)

  • No unnecessary envelopes - Return resources directly unless pagination needed

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

python:architecture

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

python:build-tools

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

python:sqlalchemy

No summary provided by upstream source.

Repository SourceNeeds Review