| name | typescript-strict |
|---|---|
| description | TypeScript strict mode patterns. Use when writing any TypeScript code. |
TypeScript Strict Mode
Core Rules
- No
any- ever. Useunknownif type is truly unknown - No type assertions (
as Type) without justification - Prefer
typeoverinterfacefor data structures - Reserve
interfacefor behavior contracts only
Schema Organization
Organize Schemas by Usage
Common patterns:
- Centralized: src/schemas/ for shared schemas
- Co-located: Near the modules that use them
- Layered: Separate by architectural layer (if using layered/hexagonal architecture)
Key principle: Avoid duplicating the same validation logic across multiple files.
Gotcha: Schema Duplication
Common anti-pattern:
Defining the same schema in multiple places: - Validation logic duplicated across endpoints - Same business rules defined in multiple adapters - Type definitions not shared
Why This Is Wrong: - ❌ Duplication creates multiple sources of truth - ❌ Changes require updating multiple files - ❌ Breaks DRY principle at the knowledge level - ❌ Domain logic leaks into infrastructure code
Solution:
// ✅ CORRECT - Define schema once, import everywhere
// src/schemas/user-requests.ts
import { z } from 'zod';
export const CreateUserRequestSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
// Use in multiple places
import { CreateUserRequestSchema } from '../schemas/user-requests.js';
// Express endpoint
app.post('/users', (req, res) => {
const result = CreateUserRequestSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
// Use result.data (validated)
});
// GraphQL resolver
const createUser = (input: unknown) => {
const validated = CreateUserRequestSchema.parse(input);
return userService.create(validated);
};
Key Benefits: - ✅ Single source of truth for validation - ✅ Schema changes propagate everywhere automatically - ✅ Type safety maintained across codebase - ✅ DRY principle at knowledge level
Remember: If validation logic is duplicated, extract it into a shared schema.
Dependency Injection Pattern
Inject Dependencies, Don't Create Them
The Rule:
- Dependencies are always injected via parameters
- Never use new to create dependencies inside functions
- Factory functions accept dependencies as parameters
Why This Matters
Without dependency injection: - ❌ Only one implementation possible - ❌ Can't test with mocks (poor testability) - ❌ Tight coupling to specific implementations - ❌ Violates dependency inversion principle - ❌ Can't swap implementations
With dependency injection: - ✅ Any implementation works (in-memory, database, remote API) - ✅ Fully testable (inject mocks for testing) - ✅ Loose coupling - ✅ Follows dependency inversion principle - ✅ Runtime flexibility (configure implementation)
Example: Order Processor
❌ WRONG - Creating implementation internally
export const createOrderProcessor = ({
paymentGateway,
}: {
paymentGateway: PaymentGateway;
}): OrderProcessor => {
// ❌ Hardcoded implementation!
const orderRepository = new InMemoryOrderRepository();
return {
processOrder(order) {
const payment = paymentGateway.charge(order.total);
if (!payment.success) {
return { success: false, error: payment.error };
}
orderRepository.save(order); // Using hardcoded repository
return { success: true, data: order };
},
};
};
Why this is WRONG: - Only ONE repository implementation possible (in-memory) - Can't test with mock repository - Can't swap to database repository or remote API - Tight coupling to specific implementation
✅ CORRECT - Injecting all dependencies
export const createOrderProcessor = ({
paymentGateway, // ✅ Injected
orderRepository, // ✅ Injected
}: {
paymentGateway: PaymentGateway;
orderRepository: OrderRepository;
}): OrderProcessor => {
return {
processOrder(order) {
const payment = paymentGateway.charge(order.total);
if (!payment.success) {
return { success: false, error: payment.error };
}
orderRepository.save(order); // Delegate to injected dependency
return { success: true, data: order };
},
};
};
Why this is CORRECT: - ✅ Any OrderRepository implementation works (in-memory, PostgreSQL, MongoDB) - ✅ Any PaymentGateway implementation works (Stripe, mock, testing) - ✅ Easy to test (inject mocks) - ✅ Loose coupling (depends on interfaces, not implementations) - ✅ Runtime flexibility (choose implementation at startup)
Type vs Interface - Understanding WHY
The choice between type and interface is architectural, not stylistic.
Behavior Contracts → Use interface
When to use: Interfaces define contracts that must be implemented.
Examples: UserRepository, PaymentGateway, EmailService, CacheProvider
Why interface for behavior contracts?
-
Signals implementation contracts clearly - Interface communicates "this must be implemented elsewhere" - Type communicates "this is a data structure"
-
Better TypeScript errors when implementing -
class X implements UserRepositorygives clear errors - Types don't haveimplementskeyword -
Conventional for dependency injection - Standard pattern for dependency inversion - Clear separation between contract and implementation
-
Class-friendly for implementations - Many libraries use classes for services - Classes naturally implement interfaces
Example:
// Behavior contract
export interface UserRepository {
findById(id: string): Promise<User | undefined>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}
// Concrete implementation
export class PostgresUserRepository implements UserRepository {
async findById(id: string): Promise<User | undefined> {
// Implementation
}
// ... other methods
}
Data Structures → Use type
When to use: Types define immutable data structures.
Examples: User, Order, Config, ApiResponse
Why type for data?
-
Emphasizes immutability - Types with
readonlysignal "don't mutate this" - Functional programming alignment -
Better for unions, intersections, mapped types -
type Result<T, E> = Success<T> | Failure<E>-type Partial<T> = { [P in keyof T]?: T[P] } -
Prevents accidental mutations -
readonlyproperties enforce immutability at type level - Compiler catches mutation attempts -
More flexible composition - Easier to compose with utility types - Better inference in complex scenarios
Example:
// Data structure
export type User = {
readonly id: string;
readonly email: string;
readonly name: string;
readonly roles: ReadonlyArray<string>;
};
export type Order = {
readonly id: string;
readonly userId: string;
readonly items: ReadonlyArray<OrderItem>;
readonly total: number;
};
Architectural Pattern
This pattern supports clean architecture:
- Behavior contracts (
interface) = Boundaries between layers - Data structures (
type) = Data flowing through the system - Business logic depends on interfaces, not implementations
- Data is immutable (types with
readonly)
Strict Mode Configuration
tsconfig.json Settings
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"forceConsistentCasingInFileNames": true,
"allowUnusedLabels": false
}
}
What Each Setting Does
Core strict flags:
- strict: true - Enables all strict type checking options
- noImplicitAny - Error on expressions/declarations with implied any type
- strictNullChecks - null and undefined have their own types (not assignable to everything)
- noUnusedLocals - Error on unused local variables
- noUnusedParameters - Error on unused function parameters
- noImplicitReturns - Error when not all code paths return a value
- noFallthroughCasesInSwitch - Error on fallthrough cases in switch statements
Additional safety flags (CRITICAL):
- noUncheckedIndexedAccess - Array/object access returns T | undefined (prevents runtime errors from assuming elements exist)
- exactOptionalPropertyTypes - Distinguishes property?: T from property: T | undefined (more precise types)
- noPropertyAccessFromIndexSignature - Requires bracket notation for index signature properties (forces awareness of dynamic access)
- forceConsistentCasingInFileNames - Prevents case sensitivity issues across operating systems
- allowUnusedLabels - Error on unused labels (catches accidental labels that do nothing)
Additional Rules
- No
@ts-ignorewithout explicit comments explaining why - These rules apply to test code as well as production code
Architectural Insight: noUnusedParameters Catches Design Issues
The noUnusedParameters rule can reveal architectural problems:
Example: A function with an unused parameter often indicates the parameter belongs in a different layer. Strict mode catches these design issues early.
Immutability Patterns
Use readonly on All Data Structures
// ✅ CORRECT - Immutable data structure
type ApiRequest = {
readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
readonly url: string;
readonly headers?: {
readonly [key: string]: string;
};
readonly body?: unknown;
};
// ❌ WRONG - Mutable data structure
type ApiRequest = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
url: string;
headers?: {
[key: string]: string;
};
body?: unknown;
};
ReadonlyArray vs Array
// ✅ CORRECT - Immutable array
type ShoppingCart = {
readonly id: string;
readonly items: ReadonlyArray<CartItem>;
};
// ❌ WRONG - Mutable array
type ShoppingCart = {
readonly id: string;
readonly items: CartItem[];
};
Result Type Pattern for Error Handling
Prefer Result<T, E> types over exceptions for expected errors:
export type Result<T, E = Error> =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: E };
// Usage
export const findUser = (
userId: string,
): Result<User> => {
const user = database.findById(userId);
if (!user) {
return { success: false, error: new Error('User not found') };
}
return { success: true, data: user };
};
Why result types? - Explicit error handling (type system enforces checking) - No hidden control flow (unlike exceptions) - Functional programming alignment - Easier to test (no try/catch needed)
Factory Pattern for Object Creation
Use Factory Functions (Not Classes)
// ✅ CORRECT - Factory function
export const createOrderService = (
orderRepository: OrderRepository,
paymentGateway: PaymentGateway,
): OrderService => {
return {
async createOrder(order) {
const validation = validateOrder(order);
if (!validation.success) {
return validation;
}
await orderRepository.save(order);
return { success: true, data: order };
},
async processPayment(orderId, paymentInfo) {
const order = await orderRepository.findById(orderId);
if (!order) {
return { success: false, error: new Error('Order not found') };
}
return paymentGateway.charge(order.total, paymentInfo);
},
};
};
// ❌ WRONG - Class-based creation
export class OrderService {
constructor(
private orderRepository: OrderRepository,
private paymentGateway: PaymentGateway,
) {}
async createOrder(order: Order) {
// Implementation with `this`
}
}
Why factory functions?
- Functional programming alignment
- No this context issues
- Easier to compose
- Natural dependency injection
- Simpler testing (no new keyword)
Location Guidance
Suggested File Organization
These are common patterns, not strict rules. Adapt to your project's needs.
Interfaces (Behavior Contracts)
- Common locations: src/interfaces/, src/contracts/, src/ports/
- Examples: UserRepository, PaymentGateway, EmailService
- Why: Behavior contracts that define boundaries between layers
Types (Data Structures)
- Common locations: src/types/, src/models/, co-located with features
- Examples: User, Order, Config
- Why: Immutable data structures used throughout the system
Schemas (Validation)
- Common locations: src/schemas/, src/validation/, co-located with features
- Examples: UserSchema, OrderSchema, ConfigSchema
- Why: Validation rules (consider avoiding duplication)
Business Logic
- Common locations: src/services/, src/domain/, src/use-cases/
- Examples: createUserService, processOrder, validatePayment
- Why: Core business logic (prefer framework-agnostic when possible)
Implementation Details
- Common locations: src/adapters/, src/infrastructure/, src/repositories/
- Examples: PostgresUserRepository, StripePaymentGateway, RedisCache
- Why: Framework-specific code, external integrations
Note: These are suggestions based on common patterns. Your project may use different conventions. The key principles are: - Clear separation of concerns - Minimal duplication of validation logic - Dependencies point inward (toward business logic)
Schema-First at Trust Boundaries
When Schemas ARE Required
- Data crosses trust boundary (external → internal)
- Type has validation rules (format, constraints)
- Shared data contract between systems
- Used in test factories (validate test data completeness)
// API responses, user input, external data
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
// Validate at boundary
const user = UserSchema.parse(apiResponse);
When Schemas AREN'T Required
- Pure internal types (utilities, state)
- Result/Option types (no validation needed)
- TypeScript utility types (
Partial<T>,Pick<T>, etc.) - Behavior contracts (interfaces - structural, not validated)
- Component props (unless from URL/API)
// ✅ CORRECT - No schema needed
type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E };
// ✅ CORRECT - Interface, no validation
interface UserService {
createUser(user: User): void;
}
Functional Programming Principles
These principles support immutability and type safety:
Pure Functions
- No side effects (don't mutate external state)
- Deterministic (same input → same output)
- Easier to reason about, test, and compose
// ✅ CORRECT - Pure function
const addItem = (
items: ReadonlyArray<Item>,
newItem: Item,
): ReadonlyArray<Item> => {
return [...items, newItem]; // Returns new array
};
// ❌ WRONG - Impure function (mutates)
const addItem = (items: Item[], newItem: Item): void => {
items.push(newItem); // Mutates input!
};
No Data Mutation
- Use spread operators for immutable updates
- Return new objects/arrays instead of modifying
- Let TypeScript's
readonlyenforce this
// ✅ CORRECT - Immutable update
const updateUser = (
user: User,
updates: Partial<User>,
): User => {
return { ...user, ...updates }; // New object
};
// ❌ WRONG - Mutation
const updateUser = (user: User, updates: Partial<User>): void => {
Object.assign(user, updates); // Mutates!
};
Composition Over Complex Logic
- Compose small functions into larger ones
- Each function does one thing well
- Easier to understand, test, and reuse
// ✅ CORRECT - Composed functions
const validate = (input: unknown) => UserSchema.parse(input);
const saveToDatabase = (user: User) => database.save(user);
const createUser = (input: unknown) => saveToDatabase(validate(input));
// ❌ WRONG - Complex monolithic function
const createUser = (input: unknown) => {
if (typeof input !== 'object' || !input) throw new Error('Invalid');
if (!('email' in input)) throw new Error('Missing email');
// ... 50 more lines of validation and registration
};
Use Array Methods Over Loops
- Prefer
map,filter,reducefor transformations - Declarative (what, not how)
- Natural immutability (return new arrays)
// ✅ CORRECT - Functional array methods
const activeUsers = users.filter(u => u.active);
const userEmails = users.map(u => u.email);
// ❌ WRONG - Imperative loops
const activeUsers = [];
for (const u of users) {
if (u.active) {
activeUsers.push(u);
}
}
Branded Types
For type-safe primitives:
type UserId = string & { readonly brand: unique symbol };
type PaymentAmount = number & { readonly brand: unique symbol };
// Type-safe at compile time
const processPayment = (userId: UserId, amount: PaymentAmount) => {
// Implementation
};
// ❌ Can't pass raw string/number
processPayment('user-123', 100); // Error
// ✅ Must use branded type
const userId = 'user-123' as UserId;
const amount = 100 as PaymentAmount;
processPayment(userId, amount); // OK
Summary Checklist
When writing TypeScript code, verify:
- [ ] No
anytypes - usingunknownwhere type is truly unknown - [ ] No type assertions without justification
- [ ] Using
typefor data structures withreadonly - [ ] Using
interfacefor behavior contracts (ports) - [ ] Schemas defined in core, not duplicated in adapters
- [ ] Ports injected via parameters, never created internally
- [ ] Factory functions for object creation (not classes)
- [ ]
readonlyon all data structure properties - [ ] Pure functions wherever possible (no mutations)
- [ ] Result types for expected errors (not exceptions)
- [ ] Strict mode enabled with all checks passing
- [ ] Artifacts in correct locations (ports/, types/, schemas/, domain/)