Skip to main content
โšก Calmops

Building Type-Safe APIs: TypeScript, Python, and Runtime Validation

Introduction

Type safety is one of the most impactful improvements you can make to your codebase. By catching errors at compile time rather than runtime, type-safe APIs reduce bugs, improve developer experience, and serve as living documentation that automatically stays in sync with the code.

This comprehensive guide covers building type-safe APIs across languagesโ€”TypeScript with Zod, Python with Pydantic, and best practices for runtime validation that catches bad data before it reaches your business logic.

Why Type Safety Matters

The Type Safety Spectrum

Level Description When Caught Examples
No Types Plain JavaScript/Python Runtime (production) undefined is not a function
Type Hints Python type hints, JSDoc IDE only VS Code warnings
Static Types TypeScript, mypy Compile time TypeScript errors
Runtime Validation Zod, Pydantic Runtime (before processing) Invalid email format
Full Stack Shared types Build + Runtime Complete type safety

Benefits of Type-Safe APIs

  1. Catch bugs early - Errors caught at build time, not production
  2. Better IDE support - Autocomplete, refactoring, inline documentation
  3. Self-documenting code - Types serve as executable documentation
  4. Safer refactoring - Change with confidence
  5. Runtime guarantees - Validate external input before processing

TypeScript API Development

Defining Comprehensive Types

Building type-safe APIs starts with well-defined types that capture all possible states:

// types/api.ts

// Union types for discrete values
type UserStatus = 'active' | 'inactive' | 'suspended';
type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';

// Enum-like with const assertions
const UserRole = {
  USER: 'user',
  ADMIN: 'admin',
  MODERATOR: 'moderator',
} as const;
type UserRole = typeof UserRole[keyof typeof UserRole];

// Paginated response wrapper
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    hasMore: boolean;
  };
}

// API Error types
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, unknown>;
  requestId?: string;
}

interface ValidationError extends ApiError {
  code: 'VALIDATION_ERROR';
  details: {
    field: string;
    message: string;
  }[];
}

// Generic API response wrapper
type ApiResponse<T> = 
  | { success: true; data: T }
  | { success: false; error: ApiError };

// Request types
interface CreateUserRequest {
  email: string;
  name: string;
  age?: number;
  role?: UserRole;
}

interface UpdateUserRequest {
  email?: string;
  name?: string;
  age?: number;
  role?: UserRole;
}

// Response types
interface UserResponse {
  id: string;
  email: string;
  name: string;
  age?: number;
  role: UserRole;
  status: UserStatus;
  createdAt: string;
  updatedAt: string;
}

Zod for Runtime Validation

Zod provides runtime validation with TypeScript inferenceโ€”define schemas once, use types everywhere:

// schemas/user.schema.ts
import { z } from 'zod';

// Basic schemas
export const EmailSchema = z.string().email();
export const NonEmptyStringSchema = z.string().min(1);
export const PositiveIntSchema = z.number().int().positive();

// User schemas
export const CreateUserSchema = z.object({
  email: EmailSchema,
  name: NonEmptyStringSchema.max(100),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['user', 'admin', 'moderator']).default('user'),
});

export const UpdateUserSchema = CreateUserSchema.partial();

export const UserResponseSchema = z.object({
  id: z.string().uuid(),
  email: EmailSchema,
  name: NonEmptyStringSchema,
  age: z.number().int().optional(),
  role: z.enum(['user', 'admin', 'moderator']),
  status: z.enum(['active', 'inactive', 'suspended']),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

export const PaginatedUserResponseSchema = z.object({
  data: z.array(UserResponseSchema),
  pagination: z.object({
    page: z.number().int().positive(),
    limit: z.number().int().positive(),
    total: z.number().int().nonnegative(),
    hasMore: z.boolean(),
  }),
});

// Infer TypeScript types from schemas
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type User = z.infer<typeof UserResponseSchema>;
export type PaginatedUsers = z.infer<typeof PaginatedUserResponseSchema>;

// Validation helper with typed errors
export function validateCreateUser(data: unknown): CreateUserInput {
  return CreateUserSchema.parse(data);
}

export function validateUpdateUser(data: unknown): UpdateUserInput {
  return UpdateUserSchema.parse(data);
}

// Safe parse with error details
export function safeValidateCreateUser(data: unknown) {
  const result = CreateUserSchema.safeParse(data);
  
  if (!result.success) {
    const errors = result.error.issues.map(issue => ({
      field: issue.path.join('.'),
      message: issue.message,
    }));
    return { success: false as const, errors };
  }
  
  return { success: true as const, data: result.data };
}

Express API with Type Safety

// routes/users.ts
import { Router, Request, Response } from 'express';
import { 
  validateCreateUser, 
  validateUpdateUser,
  CreateUserInput,
  UpdateUserInput 
} from '../schemas/user.schema';
import { UserService } from '../services/user.service';

const router = Router();
const userService = new UserService();

// Type-safe request handler
interface AuthenticatedRequest extends Request {
  user?: { id: string; role: string };
}

// GET /users - List users with pagination
router.get('/', async (req: AuthenticatedRequest, res: Response) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 10;
  
  const result = await userService.findMany({ page, limit });
  
  res.json(result);
});

// GET /users/:id - Get single user
router.get('/:id', async (req: AuthenticatedRequest, res: Response) => {
  const { id } = req.params;
  
  const user = await userService.findById(id);
  
  if (!user) {
    return res.status(404).json({
      success: false,
      error: {
        code: 'NOT_FOUND',
        message: 'User not found',
      },
    });
  }
  
  res.json({ success: true, data: user });
});

// POST /users - Create new user
router.post('/', async (req: AuthenticatedRequest, res: Response) => {
  // Validate request body - throws on invalid data
  const validatedData = validateCreateUser(req.body);
  
  const user = await userService.create(validatedData);
  
  res.status(201).json({ success: true, data: user });
});

// PATCH /users/:id - Update user
router.patch('/:id', async (req: AuthenticatedRequest, res: Response) => {
  const { id } = req.params;
  
  // Partial validation for updates
  const validatedData = validateUpdateUser(req.body);
  
  const user = await userService.update(id, validatedData);
  
  if (!user) {
    return res.status(404).json({
      success: false,
      error: {
        code: 'NOT_FOUND',
        message: 'User not found',
      },
    });
  }
  
  res.json({ success: true, data: user });
});

// DELETE /users/:id - Delete user
router.delete('/:id', async (req: AuthenticatedRequest, res: Response) => {
  const { id } = req.params;
  
  await userService.delete(id);
  
  res.status(204).send();
});

export default router;

Python API Development

Pydantic for Runtime Validation

Pydantic is Python’s answer to Zodโ€”define models with type annotations and get automatic validation:

# models/user.py
from datetime import datetime
from typing import Optional, List
from enum import Enum
from pydantic import (
    BaseModel,
    EmailStr,
    Field,
    validator,
    constr,
    conint
)

class UserRole(str, Enum):
    USER = "user"
    ADMIN = "admin"
    MODERATOR = "moderator"

class UserStatus(str, Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"
    SUSPENDED = "suspended"

# Constrained types
NonEmptyString = constr(min_length=1, max_length=100)
PositiveInt = conint(gt=0)

# Request models
class CreateUserRequest(BaseModel):
    email: EmailStr
    name: NonEmptyString
    age: Optional[conint(ge=0, le=150)] = None
    role: UserRole = UserRole.USER
    
    class Config:
        json_schema_extra = {
            "example": {
                "email": "[email protected]",
                "name": "John Doe",
                "age": 30,
                "role": "user"
            }
        }

class UpdateUserRequest(BaseModel):
    email: Optional[EmailStr] = None
    name: Optional[NonEmptyString] = None
    age: Optional[conint(ge=0, le=150)] = None
    role: Optional[UserRole] = None

# Response models
class UserResponse(BaseModel):
    id: str
    email: EmailStr
    name: NonEmptyString
    age: Optional[int] = None
    role: UserRole
    status: UserStatus
    created_at: datetime
    updated_at: datetime
    
    class Config:
        from_attributes = True

class PaginationInfo(BaseModel):
    page: int = Field(..., ge=1)
    limit: int = Field(..., ge=1, le=100)
    total: int = Field(..., ge=0)
    has_more: bool

class PaginatedUsersResponse(BaseModel):
    data: List[UserResponse]
    pagination: PaginationInfo

# API Error models
class ValidationErrorDetail(BaseModel):
    field: str
    message: str

class ApiError(BaseModel):
    code: str
    message: str
    details: Optional[List[ValidationErrorDetail]] = None
    request_id: Optional[str] = None

class ApiResponse(BaseModel):
    success: bool
    data: Optional[UserResponse] = None
    error: Optional[ApiError] = None

Custom Validators

# models/validators.py
from pydantic import (
    BaseModel,
    field_validator,
    model_validator,
    EmailStr,
    constr
)
from typing import Optional

class UserRequest(BaseModel):
    email: EmailStr
    password: str
    confirm_password: str
    age: Optional[int] = None
    
    @field_validator('password')
    @classmethod
    def validate_password_strength(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError('Password must be at least 8 characters')
        if not any(c.isupper() for c in v):
            raise ValueError('Password must contain uppercase letter')
        if not any(c.islower() for c in v):
            raise ValueError('Password must contain lowercase letter')
        if not any(c.isdigit() for c in v):
            raise ValueError('Password must contain digit')
        return v
    
    @model_validator(mode='after')
    def validate_password_match(self) -> 'UserRequest':
        if self.password != self.confirm_password:
            raise ValueError('Passwords do not match')
        return self

class OrderRequest(BaseModel):
    items: list[dict]
    shipping_address: dict
    billing_address: Optional[dict] = None
    
    @model_validator(mode='after')
    def validate_addresses(self) -> 'OrderRequest':
        if not self.billing_address:
            self.billing_address = self.shipping_address
        return self
    
    @field_validator('items')
    @classmethod
    def validate_items_not_empty(cls, v: list) -> list:
        if not v:
            raise ValueError('Order must contain at least one item')
        return v

FastAPI with Pydantic

# main.py
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse
from typing import Optional

from models.user import (
    CreateUserRequest,
    UpdateUserRequest,
    UserResponse,
    PaginatedUsersResponse,
    ApiError,
    UserRole
)

app = FastAPI(title="Type-Safe API")

# In-memory storage for demo
users_db: dict[str, UserResponse] = {}

@app.post(
    "/users",
    response_model=UserResponse,
    status_code=201,
    responses={
        400: {"model": ApiError},
        409: {"model": ApiError}
    }
)
async def create_user(request: CreateUserRequest) -> UserResponse:
    # Check for duplicate email
    existing = [u for u in users_db.values() if u.email == request.email]
    if existing:
        raise HTTPException(
            status_code=409,
            detail={
                "code": "DUPLICATE_EMAIL",
                "message": "User with this email already exists"
            }
        )
    
    # Create user (in real app, hash password)
    import uuid
    from datetime import datetime
    
    user = UserResponse(
        id=str(uuid.uuid4()),
        email=request.email,
        name=request.name,
        age=request.age,
        role=request.role,
        status="active",
        created_at=datetime.utcnow(),
        updated_at=datetime.utcnow()
    )
    
    users_db[user.id] = user
    return user

@app.get(
    "/users",
    response_model=PaginatedUsersResponse
)
async def list_users(
    page: int = Query(1, ge=1),
    limit: int = Query(10, ge=1, le=100),
    role: Optional[UserRole] = None
) -> PaginatedUsersResponse:
    users = list(users_db.values())
    
    # Filter by role if provided
    if role:
        users = [u for u in users if u.role == role]
    
    total = len(users)
    start = (page - 1) * limit
    end = start + limit
    page_data = users[start:end]
    
    return PaginatedUsersResponse(
        data=page_data,
        pagination={
            "page": page,
            "limit": limit,
            "total": total,
            "has_more": end < total
        }
    )

@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: str) -> UserResponse:
    user = users_db.get(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@app.patch("/users/{user_id}", response_model=UserResponse)
async def update_user(
    user_id: str,
    request: UpdateUserRequest
) -> UserResponse:
    user = users_db.get(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    
    # Update fields
    update_data = request.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(user, field, value)
    
    from datetime import datetime
    user.updated_at = datetime.utcnow()
    
    users_db[user_id] = user
    return user

@app.delete("/users/{user_id}", status_code=204)
async def delete_user(user_id: str) -> None:
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail="User not found")
    del users_db[user_id]

Cross-Language Type Sharing

Generating Types from Schema

Share validation logic across languages using JSON Schema:

// Generate JSON Schema from Zod
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1),
});

const jsonSchema = zodToJsonSchema(UserSchema, 'user');
// Outputs JSON Schema that can be shared with Python, Go, etc.
# Generate Pydantic from JSON Schema
# using pydantic-from-json-schema or manual conversion
from pydantic import BaseModel, EmailStr

# Manual: JSON Schema to Pydantic conversion
# {
#   "type": "object",
#   "properties": {
#     "email": {"type": "string", "format": "email"},
#     "name": {"type": "string", "minLength": 1}
#   },
#   "required": ["email", "name"]
# }

class User(BaseModel):
    email: EmailStr
    name: str

OpenAPI/Swagger Integration

Both FastAPI and Express generate OpenAPI specs automatically:

# FastAPI - Automatic OpenAPI
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

class User(BaseModel):
    email: EmailStr
    name: str

@app.post("/users", response_model=User)
async def create_user(user: User):
    return user

# OpenAPI available at /openapi.json
# Swagger UI at /docs
// Express with tsoa or swagger-jsdoc
import { Controller, Get, Post, Body, Route } from 'tsoa';

@Route('users')
export class UsersController extends Controller {
  @Post()
  public async createUser(
    @Body() body: { email: string; name: string }
  ): Promise<{ id: string; email: string; name: string }> {
    // Implementation
    return { id: '1', email: body.email, name: body.name };
  }
}

Error Handling Patterns

Typed Error Responses

// errors/api-errors.ts

// Discriminated union for error handling
type ApiErrorType =
  | { type: 'VALIDATION'; errors: ValidationError[] }
  | { type: 'NOT_FOUND'; resource: string; id: string }
  | { type: 'UNAUTHORIZED'; message: string }
  | { type: 'FORBIDDEN'; message: string }
  | { type: 'RATE_LIMIT'; retryAfter: number }
  | { type: 'INTERNAL_ERROR'; requestId: string };

class ApiError extends Error {
  constructor(
    public readonly type: ApiErrorType,
    public readonly statusCode: number
  ) {
    super();
    this.name = 'ApiError';
  }

  toResponse() {
    switch (this.type.type) {
      case 'VALIDATION':
        return {
          success: false,
          error: {
            code: 'VALIDATION_ERROR',
            message: 'Validation failed',
            details: this.type.errors,
          },
        };
      case 'NOT_FOUND':
        return {
          success: false,
          error: {
            code: 'NOT_FOUND',
            message: `${this.type.resource} not found`,
          },
        };
      // ... other cases
    }
  }
}

// Usage with type narrowing
function handleError(error: unknown) {
  if (error instanceof ApiError) {
    return error.toResponse();
  }
  return { success: false, error: { code: 'UNKNOWN', message: 'An unexpected error occurred' } };
}

Best Practices

1. Define Types at API Boundaries

// Always validate at the API boundary
// Controller layer
async function handler(req: Request, res: Response) {
  // Validate immediately
  const input = validateRequest(req.body);
  
  // After this, use typed objects
  await processUser(input);
}

2. Use Discriminated Unions for States

// Instead of optional fields
type UserResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: ApiError };

// Enables exhaustive checking
function handleUser(response: UserResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserCard user={response.data} />;
    case 'error':
      return <ErrorMessage error={response.error} />;
  }
}

3. Never Use any

// Bad
function parseData(data: any): any { ... }

// Good
function parseData(data: unknown): User | null {
  if (!isUser(data)) return null;
  return data;
}

4. Enable Strict TypeScript

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true
  }
}

5. Validate External Input

# Always validate, never trust
@app.post("/webhook")
async def handle_webhook(payload: dict):
    # Validate immediately
    validated = WebhookPayload.parse_obj(payload)
    # Process with confidence

Testing Type Safety

Property-Based Testing

// Generate random valid inputs
import { z } from 'zod';
import { faker } from '@faker-js/faker';

const UserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
});

function generateRandomUser(): User {
  return {
    email: faker.internet.email(),
    name: faker.person.fullName(),
  };
}

// Test with many random inputs
for (let i = 0; i < 100; i++) {
  const user = generateRandomUser();
  const result = UserSchema.safeParse(user);
  console.assert(result.success, 'Generated valid user');
}

Conclusion

Type-safe APIs are essential for building robust, maintainable applications. By combining static type checking with runtime validation, you catch errors early and maintain confidence as your codebase grows.

Key takeaways:

  • Use Zod (TypeScript) or Pydantic (Python) for runtime validation
  • Define types at API boundaries and validate immediately
  • Never use anyโ€”use unknown with type guards
  • Share types between client and server where possible
  • Enable strict mode in TypeScript

The initial investment in type safety pays dividends through fewer bugs, better developer experience, and easier refactoring.

Resources

Comments