martinffx/typescript-skills · Archived

typescript-api-design

REST API design patterns. Use when designing endpoints, error responses, pagination, versioning, or API structure. Framework-agnostic principles for building consistent, maintainable APIs.

First seen Aug 2, 2026

Installation

$ npx skills add martinffx/typescript-skills --skill typescript-api-design

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from martinffx/typescript-skills.

npx skills add martinffx/typescript-skills

Browse all from martinffx/typescript-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

License LICENSE
Default branch main
Open issues 0
Status Archived

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 11,129 B
  • docs SUMMARY.md 217 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 6 installs

SKILL.md

API Design Patterns

Inspect the owning package and existing implementation first. Reuse established project types, helpers, errors, lifecycle behavior, and test utilities. The patterns below are options, not an implementation checklist. Introduce one only when the current task requires it.

Project-specific rules

  • Preserve existing API contracts during internal migrations.
  • Apply versioning, pagination, error-format, status-code, and idempotency guidance

only when the task explicitly changes the public API.

  • Do not turn REST preferences into unrelated migration scope.

Use the following patterns only when the task designs or explicitly changes the public API. For internal work, preserve the current routes, payloads, errors, status codes, pagination, and idempotency behavior.

Additional References

  • [references/error-responses.md](./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

When a public breaking change requires a new version and the project uses path 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

Reuse the current public error format. Use RFC 7807 only when the task explicitly changes the public error contract and selects this 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](./references/error-responses.md) for complete examples.

Pagination (Cursor-Based)

When the task changes a public collection contract and current query behavior requires cursor pagination, use an opaque cursor that matches the existing data model:

// 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

When changing the public API, choose status codes consistently with the existing contract and framework behavior:

# 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

Add idempotency keys only when the public API task requires replay-safe writes and the current implementation does not already provide that behavior:

// 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

When the task changes the public API:

  1. Start from the existing resource names, versioning strategy, and wire formats.
  2. Choose error formats, status codes, pagination, and idempotency as part of that

explicit contract change.

  1. Reuse established timestamp, filtering, sorting, and envelope conventions.
  2. Keep unrelated REST preferences outside migration and refactoring scope.