Best Practices
Best practices and patterns for using ts-contract effectively in production applications.
Contract Design
Use Shared Schemas
Extract common schemas to avoid duplication:
import { z } from 'zod';
// Shared schemas
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const ErrorSchema = z.object({
message: z.string(),
});
const PaginationSchema = z.object({
page: z.string().optional(),
limit: z.string().optional(),
});
// Use in contract
const contract = createContract({
users: {
list: {
method: 'GET',
path: '/users',
query: PaginationSchema,
responses: {
200: z.object({
users: z.array(UserSchema),
total: z.number(),
}),
500: ErrorSchema,
},
},
get: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
404: ErrorSchema,
},
},
},
});Version Your APIs
Include version in the path for breaking changes:
const contract = createContract({
v1: {
users: {
get: {
method: 'GET',
path: '/api/v1/users/:id',
// ...
},
},
},
v2: {
users: {
get: {
method: 'GET',
path: '/api/v2/users/:id',
// Different schema for v2
},
},
},
});Document with Summaries
Use the summary field for documentation:
const contract = createContract({
users: {
get: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
404: ErrorSchema,
},
summary: 'Retrieve a user by their unique identifier',
},
},
});Use Metadata for Custom Properties
Store additional information in metadata:
const contract = createContract({
users: {
get: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
},
metadata: {
requiresAuth: true,
rateLimit: 100,
cacheTTL: 300,
tags: ['users', 'public'],
},
},
},
});Type Safety
Always Use typeof
When extracting types, always use typeof:
// ✓ Correct
type User = InferResponseBody<typeof contract.users.get, 200>;
// ✗ Wrong
type User = InferResponseBody<contract.users.get, 200>;Avoid any and Type Assertions
Let TypeScript infer types from your contract:
// ✓ Good
const user = api.users.get.validateResponse(200, data);
// user is properly typed
// ✗ Avoid
const user = data as any;
const user = data as User; // Only if absolutely necessaryUse Type Guards for Unions
Create type guards for discriminated unions:
type Response =
| { status: 200; body: User }
| { status: 404; body: { message: string } };
function isSuccessResponse(res: Response): res is { status: 200; body: User } {
return res.status === 200;
}
if (isSuccessResponse(response)) {
console.log(response.body.name); // TypeScript knows this is User
}Export Types from Contract Package
In a monorepo, export types from your contract package:
// packages/contract/src/types.ts
export type User = InferResponseBody<typeof contract.users.get, 200>;
export type CreateUserBody = InferBody<typeof contract.users.create>;
export type UpdateUserBody = InferBody<typeof contract.users.update>;
// apps/api/src/routes/users.ts
import type { User, CreateUserBody } from '@my-app/contract';Validation
Validate at Boundaries
Always validate data at system boundaries:
// ✓ Good - validate incoming data
app.post('/users', (req, res) => {
const body = api.users.create.validateBody(req.body);
const user = await database.createUser(body);
res.json(user);
});
// ✗ Bad - no validation
app.post('/users', (req, res) => {
const user = await database.createUser(req.body);
res.json(user);
});Validate API Responses
Validate responses from external APIs:
// ✓ Good
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return api.users.get.validateResponse(200, data);
}
// ✗ Bad - no validation
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}Handle Validation Errors
Provide meaningful error messages:
try {
const body = api.users.create.validateBody(req.body);
} catch (error) {
if (error.name === 'ZodError') {
return res.status(400).json({
message: 'Validation failed',
errors: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
})),
});
}
throw error;
}Conditional Validation
Skip validation in trusted environments:
const shouldValidate = process.env.NODE_ENV !== 'production';
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return shouldValidate
? api.users.get.validateResponse(200, data)
: data;
}Plugin Usage
Initialize Once, Export
Create your enhanced contract once:
// api.ts
import { initContract } from '@ts-contract/core';
import { pathPlugin, validatePlugin } from '@ts-contract/plugins';
import { contract } from './contract';
export const api = initContract(contract)
.use(pathPlugin)
.use(validatePlugin)
.build();// Other files
import { api } from './api';Use Plugins Consistently
Apply the same plugins across your application:
// ✓ Good - consistent
const api = initContract(contract)
.use(pathPlugin)
.use(validatePlugin)
.build();
// ✗ Avoid - inconsistent
const clientApi = initContract(contract).use(pathPlugin).build();
const serverApi = initContract(contract).use(validatePlugin).build();Choose Plugins Based on Needs
Client-side:
// Client needs both path building and validation
const api = initContract(contract)
.use(pathPlugin)
.use(validatePlugin)
.build();Server-side:
// Server only needs validation (paths are defined by framework)
const api = initContract(contract)
.use(validatePlugin)
.build();Error Handling
Use Consistent Error Responses
Define standard error schemas:
const ErrorSchema = z.object({
message: z.string(),
code: z.string().optional(),
details: z.record(z.any()).optional(),
});
const contract = createContract({
users: {
get: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
400: ErrorSchema,
401: ErrorSchema,
404: ErrorSchema,
500: ErrorSchema,
},
},
},
});Create Custom Error Classes
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public code?: string,
public details?: Record<string, any>
) {
super(message);
this.name = 'ApiError';
}
}
// Usage
throw new ApiError('User not found', 404, 'USER_NOT_FOUND');Handle Errors Gracefully
async function fetchUser(id: string) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
if (response.status === 404) {
const error = await response.json();
throw new ApiError(error.message, 404);
}
throw new ApiError('Request failed', response.status);
}
const data = await response.json();
return api.users.get.validateResponse(200, data);
} catch (error) {
if (error instanceof ApiError) {
// Handle API errors
console.error('API Error:', error.message);
} else {
// Handle network errors
console.error('Network Error:', error);
}
throw error;
}
}Performance
Minimize Bundle Size
Only import what you need:
// ✓ Good - tree-shakeable
import { createContract } from '@ts-contract/core';
import { pathPlugin } from '@ts-contract/plugins';
// ✗ Avoid - imports everything
import * as tsContract from '@ts-contract/core';Conditional Validation
Validate only in development or at boundaries:
const shouldValidate = process.env.NODE_ENV !== 'production';
async function fetchUser(id: string) {
const response = await fetch(api.users.get.buildPath({ id }));
const data = await response.json();
return shouldValidate ? api.users.get.validateResponse(200, data) : data;
}Skip Validation in Production
For trusted internal APIs:
const api = process.env.NODE_ENV === 'production'
? initContract(contract).use(pathPlugin).build()
: initContract(contract).use(pathPlugin).use(validatePlugin).build();WebSocket Contracts
Message Type Discriminators
Always include type discriminators matching event names:
const contract = createContract({
chat: {
type: 'websocket',
path: '/ws/chat/:roomId',
pathParams: z.object({ roomId: z.string() }),
clientMessages: {
// ✓ Good - type matches event name
new_msg: z.object({
type: z.literal('new_msg'),
body: z.string(),
}),
// ✗ Avoid - missing type discriminator
typing: z.object({
isTyping: z.boolean(),
}),
},
serverMessages: {
new_msg: z.object({
type: z.literal('new_msg'),
id: z.string(),
body: z.string(),
}),
},
},
});Organize by Channel/Topic
Group WebSocket definitions by channel or topic:
const contract = createContract({
websockets: {
chat: { type: 'websocket', path: '/ws/chat/:roomId', /* ... */ },
notifications: { type: 'websocket', path: '/ws/notifications', /* ... */ },
presence: { type: 'websocket', path: '/ws/presence/:userId', /* ... */ },
},
});Validate Messages at Boundaries
Validate incoming and outgoing WebSocket messages:
// Validate incoming messages
channel.on('new_msg', (data: unknown) => {
try {
const msg = api.chat.validateServerMessage('new_msg', data);
handleMessage(msg);
} catch (error) {
console.error('Invalid message:', error);
}
});
// Validate outgoing messages
function sendMessage(body: string) {
const msg = api.chat.validateClientMessage('new_msg', {
type: 'new_msg',
body,
});
channel.push('new_msg', msg);
}Testing
Test Contract Definitions
import { describe, it, expect } from 'vitest';
import { contract } from './contract';
describe('Contract', () => {
it('has correct structure', () => {
expect(contract.users.get.method).toBe('GET');
expect(contract.users.get.path).toBe('/users/:id');
});
});Test Type Inference
import { describe, it, expectTypeOf } from 'vitest';
import type { InferResponseBody } from '@ts-contract/core';
import { contract } from './contract';
describe('Type Inference', () => {
it('infers correct user type', () => {
type User = InferResponseBody<typeof contract.users.get, 200>;
expectTypeOf<User>().toHaveProperty('id');
expectTypeOf<User>().toHaveProperty('name');
expectTypeOf<User>().toHaveProperty('email');
});
});Test Validation
import { describe, it, expect } from 'vitest';
import { api } from './api';
describe('Validation', () => {
it('validates correct data', () => {
const validUser = {
id: '1',
name: 'Alice',
email: '[email protected]',
};
expect(() => {
api.users.get.validateResponse(200, validUser);
}).not.toThrow();
});
it('rejects invalid data', () => {
const invalidUser = {
id: '1',
name: 'Alice',
// Missing email
};
expect(() => {
api.users.get.validateResponse(200, invalidUser);
}).toThrow();
});
});Mock API Responses
import { vi } from 'vitest';
vi.mock('./api-client', () => ({
fetchUser: vi.fn().mockResolvedValue({
id: '1',
name: 'Alice',
email: '[email protected]',
}),
}));Code Organization
Monorepo Structure
my-monorepo/
├── packages/
│ └── contract/
│ ├── src/
│ │ ├── index.ts
│ │ ├── contract.ts
│ │ ├── api.ts
│ │ ├── types.ts
│ │ └── schemas/
│ │ ├── user.ts
│ │ ├── post.ts
│ │ └── common.ts
│ └── package.json
├── apps/
│ ├── api/
│ └── web/
└── package.jsonSeparate Concerns
// schemas/user.ts
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
// schemas/common.ts
export const ErrorSchema = z.object({
message: z.string(),
});
export const PaginationSchema = z.object({
page: z.string().optional(),
limit: z.string().optional(),
});
// contract.ts
import { UserSchema } from './schemas/user';
import { ErrorSchema, PaginationSchema } from './schemas/common';
export const contract = createContract({
users: {
list: {
query: PaginationSchema,
responses: {
200: z.object({
users: z.array(UserSchema),
total: z.number(),
}),
500: ErrorSchema,
},
},
},
});Export Everything
// index.ts
export { contract } from './contract';
export { api } from './api';
export * from './types';
export * from './schemas/user';
export * from './schemas/common';Security
Validate User Input
Always validate and sanitize user input:
app.post('/users', (req, res) => {
try {
const body = api.users.create.validateBody(req.body);
// body is validated and sanitized
const user = await database.createUser(body);
res.json(user);
} catch (error) {
res.status(400).json({ message: 'Invalid input' });
}
});Don't Expose Internal Errors
// ✓ Good
app.use((err, req, res, next) => {
console.error(err); // Log internally
res.status(500).json({ message: 'Internal server error' });
});
// ✗ Bad - exposes stack traces
app.use((err, req, res, next) => {
res.status(500).json({ message: err.message, stack: err.stack });
});Use Environment Variables
const API_BASE_URL = process.env.API_URL || 'http://localhost:3000';
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET is required');
}Rate Limit Endpoints
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
});
app.use('/api/', limiter);Documentation
Document Your Contract
Add comments to your contract:
/**
* User management API endpoints
*/
export const contract = createContract({
users: {
/**
* Retrieve a user by ID
* @requires Authentication
*/
get: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
404: ErrorSchema,
},
summary: 'Get user by ID',
},
},
});Generate API Documentation
Use metadata to generate docs:
function generateDocs(contract: any) {
for (const [key, route] of Object.entries(contract)) {
if ('method' in route) {
console.log(`${route.method} ${route.path}`);
console.log(`Summary: ${route.summary}`);
console.log(`Metadata:`, route.metadata);
}
}
}Keep README Updated
Document your contract package:
# @my-app/contract
Shared API contract for My App.
## Installation
\`\`\`bash
pnpm add @my-app/contract
\`\`\`
## Usage
\`\`\`ts
import { api, type User } from '@my-app/contract';
const user = await fetchUser('123');
\`\`\`Deployment
Build Contract Package
{
"scripts": {
"build": "tsc",
"prepublishOnly": "pnpm build"
}
}Version Carefully
Use semantic versioning for breaking changes (major), new features (minor), and bug fixes (patch).
Common Pitfalls
Don't Mix Concerns
// ✗ Bad - mixing business logic with contract
const contract = createContract({
users: {
get: {
method: 'GET',
path: '/users/:id',
responses: {
200: UserSchema,
},
// Don't add business logic here
handler: async (id) => database.findUser(id),
},
},
});Don't Over-Validate
// ✗ Bad - validating trusted internal data
function processUser(user: User) {
// Don't validate if you already know it's valid
const validated = api.users.get.validateResponse(200, user);
// ...
}
// ✓ Good - only validate at boundaries
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return api.users.get.validateResponse(200, data);
}Don't Ignore TypeScript Errors
// ✗ Bad - using @ts-ignore
// @ts-ignore
const user = api.users.get.buildPath({ id: 123 });
// ✓ Good - fix the type error
const user = api.users.get.buildPath({ id: '123' });Summary
- Design contracts around domains, not technical layers
- Use shared schemas to avoid duplication
- Always validate at boundaries (API responses, user input)
- Export types from your contract package
- Initialize plugins once and export
- Handle errors gracefully with consistent error schemas
- Test your contracts and type inference
- Document your API with summaries and metadata
- Use semantic versioning for breaking changes
- Keep security in mind - validate input, don't expose internals
Next Steps
- Review Contracts for organizing your API
- See WebSocket Contracts for real-time APIs
- Explore Type Inference for extracting types
- Check out FAQ for common questions