Skip to main content

typescript-patterns

TypeScript patterns for strict, type-safe, maintainable code in modern TypeScript and JavaScript applications. Covers strict compiler configuration, type design, narrowing, discriminated unions, generics, async and error handling, module boundaries, dependency hygiene, and type-level testing. Use when writing or reviewing TypeScript or JavaScript code.

Source facts

Repository
affaan-m/ECC
Last source activity
October 30, 2026 at 00:25
Detected SKILL.md language
English
Stars
294,201
Forks
43,555

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
typescript-patterns
description
TypeScript patterns for strict, type-safe, maintainable code in modern TypeScript and JavaScript applications. Covers strict compiler configuration, type design, narrowing, discriminated unions, generics, async and error handling, module boundaries, dependency hygiene, and type-level testing. Use when writing or reviewing TypeScript or JavaScript code.
metadata
{"origin":"ECC"}
# TypeScript Development Patterns TypeScript patterns and best practices for building robust, type-safe, and maintainable applications. The compiler is most useful when it can prove your program's shape, so the guiding principle here is: push the type system to do real work instead of fighting it. This skill is the "how." For the "what" and "when" (the decisions about which pattern to reach for), see the TypeScript rules — `rules/typescript/coding-style.md` and `rules/typescript/patterns.md` in this repository, installed as `rules/ecc/typescript/coding-style.md` and `rules/ecc/typescript/patterns.md`. ## When to Activate - Writing new TypeScript or JavaScript code - Reviewing a PR that touches `.ts` or `.tsx` files - Refactoring existing TypeScript to remove `any`, tighten types, or add strictness - Designing types, generics, or module boundaries for a new feature - Setting up a new TypeScript project or upgrading `tsconfig.json` - Debugging type errors that cascade through a codebase (usually a design problem, not an annotation problem) ## Core Principles ### 1. Strict Mode Is the Baseline `strict: true` (which enables `strictNullChecks`, `noImplicitAny`, `strictFunctionTypes`, and the rest) is not optional in a healthy codebase. If a codebase cannot compile under strict, that is a migration task, not an excuse to leave the flag off. ```jsonc // tsconfig.json — a strict, discriminating baseline { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, "noUnusedLocals": true, "noUnusedParameters": true, "forceConsistentCasingInFileNames": true, "verbatimModuleSyntax": true } } ``` `noUncheckedIndexedAccess` makes array and record access possibly-undefined, which surfaces a whole class of runtime crashes at compile time. If it produces too much noise, this is the pattern to add, not the flag to remove: ```typescript // If you have proven the index exists, lift the element into a local first. const [first] = items if (first === undefined) { throw new Error("expected at least one item") } ``` ### 2. `any` Is a Bug, Not a Tool `any` disables the type system exactly where a bug is most likely to hide: at the boundary with untrusted or dynamic data. Prefer `unknown` and narrow it. When you truly need a fallback, prefer an explicit loosening with a comment over a silent `any`. ```typescript // Bad: any disables checking on the public API export function parseConfig(raw: any): Config { return raw.config ?? {} } interface Config { timeout: number retries: number } const DEFAULT_CONFIG: Config = { timeout: 30_000, retries: 3 } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null } function toPositiveInt(value: unknown, field: string): number { if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { throw new TypeError(`Expected ${field} to be a positive integer`) } return value } // Good: unknown forces safe narrowing at the edge. Every consumed field is // validated with an explicit range, and defaults are declared up front. export function parseConfig(raw: unknown): Config { if (!isRecord(raw)) return { ...DEFAULT_CONFIG } return { timeout: raw['timeout'] === undefined ? DEFAULT_CONFIG.timeout : toPositiveInt(raw['timeout'], 'timeout'), retries: raw['retries'] === undefined ? DEFAULT_CONFIG.retries : toPositiveInt(raw['retries'], 'retries'), } } ``` ### 3. Public APIs Get Explicit Types, Locals Get Inference TypeScript infers local variables well. Exporting functions whose parameter and return types are implicit leaks inference decisions into every consumer and makes intent unreadable. ```typescript // Bad: exported API without explicit types export function formatUser(user) { return `${user.firstName} ${user.lastName}` } // Good: explicit public contract, inferred internals interface User { firstName: string lastName: string } export function formatUser(user: User): string { return `${user.firstName} ${user.lastName}` } ``` ## Type Design ### Interfaces vs. Type Aliases `interface` for object shapes that may be extended or implemented; `type` for unions, intersections, tuples, mapped types, and utility types. Prefer string-literal unions over `enum` unless you need runtime value enumeration or interop with code that requires it. ```typescript interface User { id: string email: string } type UserRole = 'admin' | 'member' type AdminUser = User & { role: 'admin' } ``` ### Discriminated Unions Are the Default State Machine When a value can be in multiple shapes, model it as a discriminated union with a literal `type`/`kind` field. Exhaustive checking then becomes a free compiler feature: never a runtime fallback, always a compile error on a missing branch. ```typescript type Transaction = | { kind: 'draft'; id: string } | { kind: 'submitted'; id: string; submittedAt: Date } | { kind: 'paid'; id: string; paidAt: Date; amountCents: number } function describe(t: Transaction): string { switch (t.kind) { case 'draft': return 'Saved but not sent' case 'submitted': return `Submitted on ${t.submittedAt.toISOString()}` case 'paid': return `Paid ${t.amountCents} cents on ${t.paidAt.toISOString()}` default: // exhaustive: any new member forces this to fail to compile return assertNever(t) } } function assertNever(value: never): never { throw new Error(`Unhandled member: ${JSON.stringify(value)}`) } ``` ### Brands and Nominal Safety Where Structure Is Not Enough Structural typing cannot tell `CustomerId` from `string`. When an ID or unit must not be confused with another, add a brand (a phantom field with a unique symbol key). ```typescript declare const brand: unique symbol export type CustomerId = string & { [brand]: 'CustomerId' } function ensureCustomerId(value: string): CustomerId { if (!/^cus_[a-z0-9]+$/.test(value)) { throw new TypeError(`Invalid customer id: ${value}`) } return value as CustomerId } ``` Keep brands internal to a boundary module; never expose the cast helper far from the validation that justifies it. ### Generics Model Caller-Dependent Types If a value's type depends on what the caller supplies, make it generic rather than flattening to `any` or `unknown` and hand-casting at call sites. ```typescript // Bad: caller must cast the result export function getCached(key: string): unknown { return cache.get(key) } // Good: the caller's type flows through export function getCached<T>(key: string, parse: (raw: string) => T): T | undefined { const raw = cache.get(key) if (raw === undefined) return undefined return parse(raw) } ``` Constrain generics so the contract is discoverable: prefer `T extends SomeShape` over free-floating `T` when a shape assumption exists. ### Options Objects over Positional Parameters Functions with more than a couple of knobs should take a single options object. It makes call sites self-documenting and lets new knobs be added without positional churn. ```typescript interface FetchOptions { timeoutMs?: number retries?: number signal?: AbortSignal } export async function fetchWithRetry(url: string, options: FetchOptions = {}): Promise<Response> { // ... } ``` ## Narrowing and Runtime Safety ### Validate at the Boundary, Trust Inside Untrusted data enters at I/O boundaries: API handlers, message consumers, file loaders. Parse and validate once with a schema, then carry the refined type through the domain. This is where a validation library (for example, Zod) earns its keep — the schema is both the runtime check and the compile-time type. ```typescript import { z } from 'zod' const userInputSchema = z.object({ email: z.string().email(), age: z.number().int().min(0).max(150) }) export type UserInput = z.infer<typeof userInputSchema> // validate once at the edge const input: UserInput = userInputSchema.parse(rawBody) ``` ### Type Predicates and Assertion Functions Prefer type predicates and assertion functions over unchecked casts when guarding shapes. The guard documents the invariant and the compiler checks its usage. ```typescript function isConfig(value: unknown): value is Config { if (typeof value !== 'object' || value === null) return false if (!('timeout' in value) || !('retries' in value)) return false const { timeout, retries } = value return ( typeof timeout === 'number' && Number.isInteger(timeout) && timeout > 0 && typeof retries === 'number' && Number.isInteger(retries) && retries > 0 ) } ``` To fail loudly on a malformed value instead of returning a boolean, use an assertion signature: ```typescript function assertIsStringArray(value: unknown): asserts value is string[] { if (!Array.isArray(value)) { throw new TypeError('Expected an array of strings') } // Iteration visits sparse positions as undefined instead of skipping them. for (const item of value) { if (typeof item !== 'string') throw new TypeError('Expected an array of strings') } } ``` ### Readonly by Default Mark array and object parameters `readonly`/`ReadonlyArray` when the function does not mutate them. It prevents accidental mutation and signals intent at the signature. (Per the coding-style rules, prefer the spread operator for immutable updates.) ```typescript export function totalAmount(items: readonly LineItem[]): number { return items.reduce((sum, item) => sum + item.amountCents, 0) } ``` Runtime immutability (for example, `Object.freeze`) is for hot application boundaries; `readonly` in type positions is the everyday guard. ## Error Handling ### Fail at the Boundary, Handle at the Edge The `try/catch` belongs where the caller can act on failure — not wrapped around every internal call in a stack of swallowed errors. Use async/await with `try/catch` and narrow `unknown` errors safely (per the coding-style rules). This endpoint example accepts non-empty IDs made of ASCII letters, digits, underscores, and hyphens, such as `u_1`. Validate that contract before constructing the path or making a request. Adapt the identifier grammar to the real API. ```typescript type User = { id: string; email: string } function isUser(value: unknown): value is User { if (typeof value !== 'object' || value === null) return false if (!('id' in value) || !('email' in value)) return false const { id, email } = value return ( typeof id === 'string' && id.length > 0 && typeof email === 'string' && email.includes('@') ) } function assertUserId(userId: string): void { if (typeof userId !== 'string' || userId.length === 0 || /[^A-Za-z0-9_-]/.test(userId)) { throw new TypeError('Expected a user ID using letters, digits, "_" or "-"') } } async function loadUser(userId: string): Promise<User> { assertUserId(userId) const response = await fetch(`/users/${userId}`) if (!response.ok) { throw new Error(`Failed to load user ${userId}: ${response.status}`) } const data: unknown = await response.json() if (!isUser(data)) { throw new TypeError(`Invalid user payload for ${userId}`) } return data } export async function getUser(userId: string): Promise<User> { try { return await loadUser(userId) } catch (error: unknown) { if (error instanceof Error) { // known failure mode: retry or surface a friendly message throw new Error(`user lookup failed: ${error.message}`, { cause: error }) } throw new TypeError('unknown error shape') } } ``` ### Result Types for Expected Failures For expected business failures (validation, not-found, conflict), a discriminated `Result` union makes failure explicit in the type instead of hiding it in the exception flow. ```typescript type Result<T, E> = { ok: true; value: T } | { ok: false; error: E } function createUser(input: UserInput): Result<User, ValidationIssue[]> { const issues = validate(input) if (issues.length > 0) { return { ok: false, error: issues } } return { ok: true, value: { id: generateId(), ...input } } } ``` Reserve exceptions for the unexpected. Expected outcomes belong in the return type. ### Preserve Error Context Use `new Error(message, { cause })` (available since ES2022) so the original failure chain survives rewrapping. Never stringify errors into a message and lose the cause. ## Async Patterns ### Prefer `Promise.allSettled` for Independent Fan-Out When results are independent and one failure must not drop the others, use `Promise.allSettled` and handle the per-promise outcomes. ```typescript const results: PromiseSettledResult<number>[] = await Promise.allSettled( sourceIds.map((id) => fetchCount(id)) ) const total = results.reduce((sum, result) => { if (result.status === 'fulfilled') { return sum + result.value } log.warn('count fetch failed', result.reason) return sum }, 0) ``` Use `Promise.all` when you want fail-fast semantics — genuinely all-or-nothing work. ### Avoid Unbounded Concurrency Fanning out hundreds of promises at once overwhelms connections and event loop. Bound the concurrency with a small pool. ```typescript async function mapWithConcurrency<T, R>( items: readonly T[], limit: number, mapper: (item: T) => Promise<R> ): Promise<R[]> { if (!Number.isInteger(limit) || limit < 1) { throw new RangeError(`Expected concurrency limit to be a positive integer, got: ${limit}`) } const results: R[] = new Array(items.length) let nextIndex = 0 const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (true) { const index = nextIndex++ if (index >= items.length) return // Required under noUncheckedIndexedAccess: the index is proven // in-bounds above, but the compiler still types this T | undefined. // Skip only genuine sparse-array holes, mirroring Array.prototype.map: // a present `undefined` element is a legitimate value and must still // reach the mapper. if (!Object.hasOwn(items, index)) continue // Presence is proven above, so the indexed access is in-bounds and // filled — never a hole and never the compiler's speculative undefined. results[index] = await mapper(items[index]!) } }) // One worker rejecting rejects the whole pool: Promise.all propagates the // first rejection to the caller (fail-fast). That is rejection, not // cancellation — the other workers keep running and may settle more of // their remaining items after the pool has already rejected. Use // Promise.all only when leaving that in-flight work running is acceptable; // otherwise use allSettled or thread an AbortSignal through the mapper. await Promise.all(workers) return results } ``` ### Timeouts and Cancellation Always thread `AbortSignal` through long-running or outbound work so callers can cancel; pair it with `Promise.race` for hard timeouts where the underlying operation cannot be aborted. ```typescript
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub