home / skills / citypaul / .dotfiles / typescript-strict
This skill enforces TypeScript strict mode patterns to improve type safety, reduce any usage, and promote immutable data structures across code.
npx playbooks add skill citypaul/.dotfiles --skill typescript-strictReview the files below or copy the command above to add this skill to your agents.
---
name: typescript-strict
description: TypeScript strict mode patterns. Use when writing any TypeScript code.
---
# TypeScript Strict Mode
## Core Rules
1. **No `any`** - ever. Use `unknown` if type is truly unknown
2. **No type assertions** (`as Type`) without justification
3. **Prefer `type` over `interface`** for data structures
4. **Reserve `interface`** for 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:**
```typescript
// ✅ 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>;
```
```typescript
// 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**
```typescript
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**
```typescript
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?**
1. **Signals implementation contracts clearly**
- Interface communicates "this must be implemented elsewhere"
- Type communicates "this is a data structure"
2. **Better TypeScript errors when implementing**
- `class X implements UserRepository` gives clear errors
- Types don't have `implements` keyword
3. **Conventional for dependency injection**
- Standard pattern for dependency inversion
- Clear separation between contract and implementation
4. **Class-friendly for implementations**
- Many libraries use classes for services
- Classes naturally implement interfaces
**Example:**
```typescript
// 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?**
1. **Emphasizes immutability**
- Types with `readonly` signal "don't mutate this"
- Functional programming alignment
2. **Better for unions, intersections, mapped types**
- `type Result<T, E> = Success<T> | Failure<E>`
- `type Partial<T> = { [P in keyof T]?: T[P] }`
3. **Prevents accidental mutations**
- `readonly` properties enforce immutability at type level
- Compiler catches mutation attempts
4. **More flexible composition**
- Easier to compose with utility types
- Better inference in complex scenarios
**Example:**
```typescript
// 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
```json
{
"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-ignore`** without 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
```typescript
// ✅ 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
```typescript
// ✅ 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:
```typescript
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)
```typescript
// ✅ 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)
```typescript
// 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)
```typescript
// ✅ 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
```typescript
// ✅ 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 `readonly` enforce this
```typescript
// ✅ 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
```typescript
// ✅ 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`, `reduce` for transformations
- Declarative (what, not how)
- Natural immutability (return new arrays)
```typescript
// ✅ 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:
```typescript
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 `any` types - using `unknown` where type is truly unknown
- [ ] No type assertions without justification
- [ ] Using `type` for data structures with `readonly`
- [ ] Using `interface` for 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)
- [ ] `readonly` on 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/)
This skill documents TypeScript strict-mode patterns and architectural conventions for writing safe, maintainable TypeScript. It focuses on strict compiler settings, immutable data, dependency injection, schema organization, and clear type vs interface guidance. Use it as a checklist and pattern guide when authoring or reviewing TypeScript code.
The skill inspects and prescribes rules for code organization, type safety, and runtime validation: strict tsconfig flags, no implicit any or unchecked indexed access, and explicit immutability with readonly. It recommends schema-first validation at trust boundaries, single-source schemas, dependency injection for testability, and using types for data and interfaces for behavior contracts.
Why prefer type over interface for data?
Types express immutable data, compose well with unions and mapped types, and work naturally with readonly and utility types for safer data modeling.
When should I keep validation logic co-located?
Co-locate validation when a schema is small and used only by one module; extract to shared schemas when the same rules are used by multiple modules or trust boundaries.