typescript-strict
npx skills add https://github.com/citypaul/dotfiles --skill typescript-strict
Agent 安装分布
Skill 文档
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
newto 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 have
implementskeyword
-
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
- Types with
-
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 optionsnoImplicitAny– Error on expressions/declarations with impliedanytypestrictNullChecks–nullandundefinedhave their own types (not assignable to everything)noUnusedLocals– Error on unused local variablesnoUnusedParameters– Error on unused function parametersnoImplicitReturns– Error when not all code paths return a valuenoFallthroughCasesInSwitch– Error on fallthrough cases in switch statements
Additional safety flags (CRITICAL):
noUncheckedIndexedAccess– Array/object access returnsT | undefined(prevents runtime errors from assuming elements exist)exactOptionalPropertyTypes– Distinguishesproperty?: Tfromproperty: 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 systemsallowUnusedLabels– 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
thiscontext issues - Easier to compose
- Natural dependency injection
- Simpler testing (no
newkeyword)
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/)