Consistency is critical. Always follow existing naming patterns in the codebase:
-
Variables and Parameters: Use
camelCase- ✅
bookList,queryParams,responseData - ❌
book_list,query-params,ResponseData
- ✅
-
Constants: Use
UPPER_SNAKE_CASE- ✅
DEFAULT_LIMIT,MAX_PAGE_SIZE - ❌
defaultLimit,maxPageSize
- ✅
-
Types and Interfaces: Use
PascalCase-
✅
Book,PaginationQuery,BookListResponse -
❌
book,paginationQuery,book_list_response -
Prefer
typeoverinterfacefor better composability and type inference -
Use
interfaceonly when declaration merging or extending classes is needed -
Avoid type bloating - reuse existing types instead of creating duplicates
-
Use intersection types (
&) for composition:type Extended = Base & Additional -
Extract types from Zod schemas using
z.infer<typeof Schema>rather than duplicating definitions -
Example of good type composition:
// ✅ Good - reuse and compose with intersection type PaginationQuery = { limit?: number; offset?: number }; type SearchQuery = PaginationQuery & { q: string }; // ❌ Bad - duplicate fields type SearchQuery = { limit?: number; offset?: number; q: string; };
-
-
Functions: Use
camelCase- ✅
getBooks,createUser,updateReading - ❌
get_books,CreateUser
- ✅
-
Contract Files: Use domain name with
.contract.tssuffix- Format:
contracts/{domain}.contract.ts - ✅
book.contract.ts,user.contract.ts,reading.contract.ts - ❌
bookContract.ts,book-contract.ts,Book.contract.ts
- Format:
-
Schema Files: Use domain name with
.schema.tssuffix- Format:
schemas/{domain}.schema.ts - ✅
book.schema.ts,user.schema.ts,mark.schema.ts - ❌
bookSchema.ts,book-schema.ts,Book.schema.ts
- Format:
-
Contracts: API endpoint definitions in
src/contracts/- Each domain has its own contract file (e.g.,
book.contract.ts) - Export route objects with endpoint definitions
- Use Zod schemas for query, body, and response validation
- Always include
summaryfor OpenAPI documentation - Pattern:
export const {domain}Routes = { endpointName: { method, path, query?, body?, responses } }
- Each domain has its own contract file (e.g.,
-
Schemas: Zod schema definitions in
src/schemas/- Define ALL Zod schemas in schema files
- Export TypeScript types using
z.infer<typeof Schema> - Group related schemas together (entity, query, response)
- Compose schemas using
.extend(),.merge(),.omit(),.partial() - Pattern: Define schema first, then export inferred type
-
Main Contract: Combine all routes in
src/contract.ts- Import all domain routes
- Use
initContract()from@ts-rest/core - Combine routes with spread operator
- Keep organized by domain with comments
-
Schema Export: Central export in
src/schema.ts- Re-export all schemas from domain files
- Allows single import point:
import { BookSchema, UserSchema } from '@/src/schema'
-
OpenAPI Generation: Script in
scripts/generateOpenApi.ts- Uses
@ts-rest/open-apito generate spec - Outputs to
openapi/openapi.json - Run via
pnpm openapi:generate
- Uses
Use Zod as the single source of truth for runtime validation and type definitions:
- Define schemas in
src/schemas/{domain}.schema.tsusing Zod - Extract TypeScript types from schemas using
z.infer<typeof Schema> - Never duplicate type definitions - if a schema exists, use
z.infer - Reuse schema fragments - compose schemas using
.extend(),.merge(),.omit(),.partial() - Always export both schema AND inferred type from schema files
Schema Composition Best Practices:
- Use
.extend()for adding fields to existing schemas - Use
.merge()for combining independent schemas (e.g., pagination + filters) - Use
.omit()for excluding fields (e.g., create/update DTOs) - Use
.partial()for making all fields optional (e.g., patch/update endpoints) - Group related schemas - entity, query parameters, response types in same file
- Keep schemas simple - avoid over-nesting or complex transformations
Examples:
// ✅ Good - Schema-first approach with type export
export const BookSchema = z.object({
id: z.string(),
title: z.string(),
author: z.string().nullable(),
});
export type Book = z.infer<typeof BookSchema>;
// ✅ Good - Compose schemas with .merge()
export const PaginationQuerySchema = z.object({
limit: z.coerce.number().optional(),
offset: z.coerce.number().optional(),
});
export const BookFilterQuerySchema = PaginationQuerySchema.merge(
z.object({
author: z.string().optional(),
subject: z.string().optional(),
}),
);
export type BookFilterQuery = z.infer<typeof BookFilterQuerySchema>;
// ✅ Good - Use .omit() for DTOs
export const CreateBookSchema = BookSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
// ✅ Good - Use .partial() for updates
export const UpdateBookSchema = BookSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
}).partial();
// ✅ Good - Response schema with nested array
export const BookListResponseSchema = z.object({
data: z.array(BookSchema),
total: z.number(),
});
export type BookListResponse = z.infer<typeof BookListResponseSchema>;
// ❌ Bad - Duplicate type definition when schema exists
const BookSchema = z.object({ id: z.string(), title: z.string() });
type Book = { id: string; title: string }; // Duplicates schema
// ❌ Bad - Manually duplicate fields instead of composing
const BookFilterQuerySchema = z.object({
limit: z.coerce.number().optional(),
offset: z.coerce.number().optional(),
author: z.string().optional(),
subject: z.string().optional(),
});When to use interface vs type:
- Default: use
typefor all type aliases, unions, intersections - Always extract types from Zod schemas using
z.infer - Use
interfaceonly when:- You need declaration merging (rare in application code)
- You're extending a class
- You're defining a contract for a plugin system
Always use these libraries when applicable:
-
Zod - Use for all runtime validation and schema definitions
- Define schemas in
src/schemas/{domain}.schema.ts - Extract types with
z.infer<typeof Schema> - Prefer Zod validation over manual type checking
- Use Zod for parsing request inputs, query parameters, and API responses
- Always export both schema and inferred type
- Example:
const result = MySchema.safeParse(data)instead of manual validation
- Define schemas in
-
@ts-rest/core - Use for defining type-safe API contracts
- Initialize contract with
initContract() - Define routes with typed request/response schemas
- Always include
summaryfor OpenAPI documentation - Use Zod schemas for all query, body, and response validation
- Initialize contract with
-
@ts-rest/open-api - Use for generating OpenAPI specifications
- Generate OpenAPI spec from ts-rest contracts
- Outputs to
openapi/openapi.json - Run via
pnpm openapi:generate
Examples:
// ✅ Good - Schema with type export
export const BookSchema = z.object({
id: z.string(),
title: z.string(),
});
export type Book = z.infer<typeof BookSchema>;
// ✅ Good - Contract with Zod schemas
export const bookRoutes = {
getAllBooks: {
method: 'GET',
path: '/api/books',
query: PaginationQuerySchema,
responses: {
200: BookListResponseSchema,
},
summary: 'Get all books with pagination',
},
};
// ✅ Good - Compose schemas for complex queries
export const BookFilterQuerySchema = PaginationQuerySchema.merge(
SortQuerySchema,
).extend({
author: z.string().optional(),
});
// ❌ Bad - Manual validation when Zod should be used
if (typeof query.limit !== 'number') throw new Error('Invalid limit');
// ❌ Bad - Defining types without schemas
type BookFilterQuery = {
limit?: number;
offset?: number;
author?: string;
};
### ES Module Compatibility
**This project uses ES modules (`"type": "module"` in package.json):**
- **Always use ES module syntax** for imports/exports
- **Never use CommonJS** (`require`, `module.exports`)
- **Use `.cjs` extension for CommonJS config files** if absolutely necessary
- ✅ `.eslintrc.cjs` - CommonJS config file
- ❌ `.eslintrc.js` - Will be treated as ES module
**Example:**
```typescript
// ✅ Good - ES module imports/exports
import { z } from 'zod';
import { BookSchema } from '@/src/schema';
export const bookRoutes = { /* ... */ };
// ❌ Bad - CommonJS (won't work)
const { z } = require('zod');
module.exports = { bookRoutes };
When adding a new API endpoint, ensure consistency and simplicity:
-
Define Schemas First in
src/schemas/{domain}.schema.ts:- Create or update Zod schemas for request/response
- Export inferred TypeScript types
- Compose existing schemas when possible (
.merge(),.extend(),.omit(),.partial()) - Keep schemas simple - avoid over-engineering
-
Create or Update Contract in
src/contracts/{domain}.contract.ts:- Import necessary schemas from
@/src/schema - Define endpoint with clear, RESTful path
- Specify HTTP method (
GET,POST,PATCH,PUT,DELETE) - Add
query,body, orheadersschemas as needed - Define
responseswith status codes and schemas - Always include
summaryfor OpenAPI documentation
- Import necessary schemas from
-
Export from Central Files:
- Add schema exports to
src/schema.tsif new schema file created - Routes are automatically included when spreading in
src/contract.ts
- Add schema exports to
-
Generate OpenAPI Spec:
- Run
pnpm openapi:generateto updateopenapi/openapi.json - Verify the generated spec includes your new endpoint
- Run
-
Keep It Simple:
- Don't over-complicate schemas with transformations
- Reuse existing query schemas (pagination, sorting, filtering)
- Use
.omit()for create DTOs,.partial()for update DTOs - Follow existing patterns in the codebase
Example Pattern:
// 1. Define schemas in src/schemas/book.schema.ts
export const BookSchema = z.object({
id: z.string(),
title: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const CreateBookSchema = BookSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
export type Book = z.infer<typeof BookSchema>;
// 2. Define contract in src/contracts/book.contract.ts
export const bookRoutes = {
createBook: {
method: 'POST',
path: '/api/books',
body: CreateBookSchema,
responses: {
201: BookSchema,
},
summary: 'Create a new book',
},
};When implementing new features or making changes, follow this systematic approach:
- Implement the requested feature with clean, maintainable code
- Follow existing code patterns and conventions
- Use TypeScript best practices
- Ensure proper error handling
- Keep solutions simple - avoid over-engineering
Type Check:
pnpm lint- Ensures TypeScript compilation succeeds
- Validates type safety across the codebase
- Fix ALL type errors before proceeding
Build:
pnpm build- Generates OpenAPI spec automatically
- Compiles TypeScript to multiple formats
- Ensures build process succeeds
Tests (if available):
pnpm test- Run all test suites
- Ensure ALL tests pass
- Add new tests for new features
README.md:
- Update usage examples if API changes
- Add documentation for new endpoints or domains
- Document schema changes if significant
- Keep examples clear and concise
IMPORTANT:
- DO NOT create separate markdown files for documentation unless explicitly requested
- DO NOT create CHANGES.md, UPDATES.md, or similar summary files
- Update README.md directly with new information
- Keep documentation concise and focused
Always regenerate after schema/contract changes:
pnpm openapi:generate- Updates
openapi/openapi.json - Ensures API documentation is current
- Validates contract definitions
Before marking work as complete:
- ✅ Type checking passes (
pnpm lint) - ✅ Build succeeds (
pnpm build) - ✅ OpenAPI spec regenerated (
pnpm openapi:generate) - ✅ All tests pass (if tests exist)
- ✅ README.md updated with new features
- ✅ No unnecessary documentation files created
- ✅ Code follows existing patterns
- ✅ Exports updated in src/schema.ts if needed
- ✅ Solutions are simple and maintainable
Core Components:
- API Contracts - Type-safe endpoint definitions using @ts-rest/core
- Zod Schemas - Runtime validation and type definitions
- OpenAPI Generation - Automatic spec generation from contracts
- Type Exports - Reusable types extracted from schemas
src/contracts/- API endpoint contract definitions by domainsrc/schemas/- Zod schema definitions by domainsrc/contract.ts- Main contract combining all routessrc/schema.ts- Central schema export filesrc/index.ts- Package entry pointopenapi/- Generated OpenAPI specificationscripts/- Build and generation scripts
This library provides:
- Type-safe API contracts for client-server communication
- Runtime validation via Zod schemas
- OpenAPI documentation auto-generated from contracts
- Shared types for both frontend and backend
- Single source of truth for API definitions
- Create schema file in
src/schemas/{domain}.schema.ts - Define Zod schemas and export types
- Create contract file in
src/contracts/{domain}.contract.ts - Import schemas and define routes
- Add export to
src/schema.ts - Import and spread routes in
src/contract.ts - Run
pnpm openapi:generate - Update README.md with examples
- Update schema in
src/schemas/{domain}.schema.tsif needed - Add route definition to
src/contracts/{domain}.contract.ts - Run
pnpm openapi:generate - Update README.md if public-facing
- Modify schemas in
src/schemas/{domain}.schema.ts - Update affected contracts if needed
- Run
pnpm lintto check types - Run
pnpm buildto verify - Update README.md if breaking changes
Always provide clear error messages:
- Validation errors should indicate what's wrong and which field failed
- Use Zod's built-in error messages for validation
- Schema errors should be descriptive
- Include context in error messages (endpoint, domain, operation)
- Avoid over-engineering - Use straightforward solutions
- Reuse existing schemas - Don't create duplicates
- Compose rather than duplicate - Use
.merge(),.extend(),.omit(),.partial() - Follow existing patterns - Match code style in similar files
- Minimal transformations - Let Zod handle coercion (e.g.,
z.coerce.number())
- Define once, use everywhere - Extract types with
z.infer - Group related schemas - Keep entity, query, and response schemas together
- Use semantic names -
PaginationQuerynotQueryParams - Export both schema and type -
export const Schemaandexport type Type - Common patterns:
- Use
.omit()for create DTOs (removeid, timestamps) - Use
.partial()for update DTOs (all fields optional) - Use
.merge()to combine query schemas (pagination + filters) - Use
z.coerce.number()for query params that need type conversion
- Use
- RESTful paths - Use standard conventions (
/api/resources/:id) - Descriptive summaries - Always include for OpenAPI docs
- Logical grouping - Group routes by domain/resource
- Consistent responses - Use common response schemas across endpoints
- Proper status codes - 200 for success, 201 for creation, etc.
- Quality over speed - Take time to do it right
- Follow patterns - Match existing code style
- Document as you go - Update README.md immediately
- Keep it simple - Don't create unnecessary complexity
- Validate everything - Use Zod for all data validation
- Type safety first - Extract types from schemas, never duplicate