Awesome-omni-skill typescript-strict
TypeScript strict mode patterns. Use when writing any TypeScript code.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/typescript-strict" ~/.claude/skills/diegosouzapw-awesome-omni-skill-typescript-strict && rm -rf "$T"
skills/development/typescript-strict/SKILL.mdTypeScript Strict Mode
Core Rules
- No
- ever. Useany
if type is truly unknownunknown - No type assertions (
) without justificationas Type - Prefer
overtype
for data structuresinterface - Reserve
for behavior contracts onlyinterface
Schema Organization
Organize Schemas by Usage
Common patterns:
- Centralized:
for shared schemassrc/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
to create dependencies inside functionsnew - 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
interfaceWhen to use: Interfaces define contracts that must be implemented.
Examples:
UserRepository, PaymentGateway, EmailService, CacheProvider
Why
for behavior contracts?interface
-
Signals implementation contracts clearly
- Interface communicates "this must be implemented elsewhere"
- Type communicates "this is a data structure"
-
Better TypeScript errors when implementing
gives clear errorsclass X implements UserRepository- Types don't have
keywordimplements
-
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
typeWhen to use: Types define immutable data structures.
Examples:
User, Order, Config, ApiResponse
Why
for data?type
-
Emphasizes immutability
- Types with
signal "don't mutate this"readonly - 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
properties enforce immutability at type levelreadonly- 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 (
) = Boundaries between layersinterface - Data structures (
) = Data flowing through the systemtype - 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:
- Enables all strict type checking optionsstrict: true
- Error on expressions/declarations with impliednoImplicitAny
typeany
-strictNullChecks
andnull
have their own types (not assignable to everything)undefined
- Error on unused local variablesnoUnusedLocals
- Error on unused function parametersnoUnusedParameters
- Error when not all code paths return a valuenoImplicitReturns
- Error on fallthrough cases in switch statementsnoFallthroughCasesInSwitch
Additional safety flags (CRITICAL):
- Array/object access returnsnoUncheckedIndexedAccess
(prevents runtime errors from assuming elements exist)T | undefined
- DistinguishesexactOptionalPropertyTypes
fromproperty?: T
(more precise types)property: T | undefined
- Requires bracket notation for index signature properties (forces awareness of dynamic access)noPropertyAccessFromIndexSignature
- Prevents case sensitivity issues across operating systemsforceConsistentCasingInFileNames
- Error on unused labels (catches accidental labels that do nothing)allowUnusedLabels
Additional Rules
- No
without explicit comments explaining why@ts-ignore - 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
readonly// ✅ 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
context issuesthis - Easier to compose
- Natural dependency injection
- Simpler testing (no
keyword)new
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
,PaymentGatewayEmailService - Why: Behavior contracts that define boundaries between layers
Types (Data Structures)
- Common locations:
,src/types/
, co-located with featuressrc/models/ - Examples:
,User
,OrderConfig - Why: Immutable data structures used throughout the system
Schemas (Validation)
- Common locations:
,src/schemas/
, co-located with featuressrc/validation/ - Examples:
,UserSchema
,OrderSchemaConfigSchema - Why: Validation rules (consider avoiding duplication)
Business Logic
- Common locations:
,src/services/
,src/domain/src/use-cases/ - Examples:
,createUserService
,processOrdervalidatePayment - Why: Core business logic (prefer framework-agnostic when possible)
Implementation Details
- Common locations:
,src/adapters/
,src/infrastructure/src/repositories/ - Examples:
,PostgresUserRepository
,StripePaymentGatewayRedisCache - 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>
, etc.)Pick<T> - 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
enforce thisreadonly
// ✅ 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
for transformationsreduce - 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
types - usingany
where type is truly unknownunknown - No type assertions without justification
- Using
for data structures withtypereadonly - Using
for behavior contracts (ports)interface - Schemas defined in core, not duplicated in adapters
- Ports injected via parameters, never created internally
- Factory functions for object creation (not classes)
-
on all data structure propertiesreadonly - 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/)