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
- Catch bugs early - Errors caught at build time, not production
- Better IDE support - Autocomplete, refactoring, inline documentation
- Self-documenting code - Types serve as executable documentation
- Safer refactoring - Change with confidence
- 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โuseunknownwith 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
- Zod Documentation - TypeScript-first validation library
- Pydantic Documentation - Python validation library
- TypeScript Strict Mode - Compiler options for type safety
- FastAPI Type Safety - FastAPI type hints guide
- tsoa - TypeScript OpenAPI generator
Comments