TypeScript House Style
Overview
Comprehensive TypeScript coding standards emphasizing type safety, immutability, and integration with Functional Core, Imperative Shell (FCIS) pattern.
Core principles:
-
Types as documentation and constraints
-
Immutability by default prevents bugs
-
Explicit over implicit (especially in function signatures)
-
Functional Core returns Results, Imperative Shell may throw
-
Configuration over decoration/magic
Quick Self-Check (Use Under Pressure)
When under deadline pressure or focused on other concerns (performance, accuracy, features), STOP and verify:
-
Using Array<T> not T[]
-
Using type not interface (unless class contract)
-
Using math.js for money/currencies/complex math
-
Parameters are readonly or Readonly<T>
-
Using unknown not any
-
Using null for absent values (not undefined )
-
Using function declarations (not const arrow) for top-level functions
-
Using named exports (not default exports)
-
Using === not ==
-
Using .sort((a, b) => a - b) for numeric arrays
-
Using parseInt(x, 10) with explicit radix
Why this matters: Under pressure, you'll default to muscle memory. These checks catch the most common violations.
Type Declarations
Type vs Interface
Always use type except for class contracts.
// GOOD: type for object shapes type UserData = { readonly id: string; name: string; email: string | null; };
// GOOD: interface for class contract interface IUserRepository { findById(id: string): Promise<User | null>; }
class UserRepository implements IUserRepository { // implementation }
// BAD: interface for object shape interface UserData { id: string; name: string; }
Rationale: Types compose better with unions and intersections, support mapped types, and avoid declaration merging surprises. Interfaces are only for defining what a class must implement.
IMPORTANT: Even when under deadline pressure, even when focused on other concerns (financial accuracy, performance optimization, bug fixes), take 2 seconds to ask: "Is this a class contract?" If no, use type . Don't default to interface out of habit.
Naming Conventions
Type Suffixes
Suffix Usage Example
FooOptions
Function parameter objects (3+ args or any optional) ProcessUserOptions
FooConfig
Persistent configuration from storage DatabaseConfig
FooResult
Discriminated union return types ValidationResult
FooFn
Function/callback types TransformFn<T>
FooProps
React component props ButtonProps
FooState
State objects (component/application) AppState
General Casing
Element Convention Example
Variables & functions camelCase userName , getUser()
Types & classes PascalCase UserData , UserService
Constants UPPER_CASE MAX_RETRY_COUNT , API_ENDPOINT
Files kebab-case user-service.ts , process-order.ts
Boolean Naming
Use is/has/can/should/will prefixes. Avoid negative names.
// GOOD const isActive = true; const hasPermission = checkPermission(); const canEdit = user.role === 'admin'; const shouldRetry = attempts < MAX_RETRIES; const willTimeout = elapsed > threshold;
// Also acceptable: adjectives for state type User = { active: boolean; visible: boolean; disabled: boolean; };
// BAD: negative names const isDisabled = false; // prefer isEnabled const notReady = true; // prefer isReady
Type Suffix Details
FooOptions - Parameter Objects
Use for functions with 3+ arguments OR any optional arguments.
type ProcessUserOptions = { readonly name: string; readonly email: string; readonly age: number; readonly sendWelcome?: boolean; };
// GOOD: destructure in body, not in parameters function processUser(options: ProcessUserOptions): void { const {name, email, age, sendWelcome = true} = options; // implementation }
// BAD: inline destructuring in parameters function processUser({name, email, age}: {name: string, email: string, age: number}) { // causes duplication when destructuring }
// BAD: not using options pattern for 3+ args function processUser(name: string, email: string, age: number, sendWelcome?: boolean) { // hard to call, positional arguments }
FooResult - Discriminated Unions
Always use discriminated unions for Result types. Integrate with neverthrow.
// GOOD: discriminated union with success/error type ValidationResult = | { success: true; data: ValidUser } | { success: false; error: ValidationError };
// GOOD: use neverthrow for Result types import {Result, ok, err} from 'neverthrow';
type ValidationError = { field: string; message: string; };
function validateUser(data: Readonly<UserData>): Result<ValidUser, ValidationError> { if (!data.email) { return err({field: 'email', message: 'Email is required'}); } return ok({...data, validated: true}); }
// Usage const result = validateUser(userData); if (result.isOk()) { console.log(result.value); // ValidUser } else { console.error(result.error); // ValidationError }
Rule: Functional Core functions should return Result<T, E> types. Imperative Shell functions may throw exceptions for HTTP errors and similar.
Functions
Declaration Style
Use function declarations for top-level functions. Use arrow functions for inline callbacks.
// GOOD: function declaration for top-level function processUser(data: Readonly<UserData>): ProcessResult { return {success: true, user: data}; }
// GOOD: arrow functions for inline callbacks const users = rawData.map(u => transformUser(u)); button.addEventListener('click', (e) => handleClick(e)); fetch(url).then(data => processData(data));
// BAD: const arrow for top-level function const processUser = (data: UserData): ProcessResult => { return {success: true, user: data}; };
Rationale: Function declarations are hoisted and more visible. Arrow functions capture lexical this and are concise for callbacks.
Const Arrow Functions
Use const foo = () => {} declarations only for stable references.
// GOOD: stable reference for React hooks const handleSubmit = (event: FormEvent) => { event.preventDefault(); // implementation };
useEffect(() => { // handleSubmit reference is stable }, [handleSubmit]);
// GOOD: long event listener passed from variable const handleComplexClick = (event: MouseEvent) => { // many lines of logic }; element.addEventListener('click', handleComplexClick);
// BAD: const arrow for regular top-level function const calculateTotal = (items: Array<Item>): number => { return items.reduce((sum, item) => sum + item.price, 0); };
// GOOD: use function declaration function calculateTotal(items: ReadonlyArray<Item>): number { return items.reduce((sum, item) => sum + item.price, 0); }
Parameter Objects
Use parameter objects for 3+ arguments OR any optional arguments.
// GOOD: options object for 3+ args type CreateUserOptions = { readonly name: string; readonly email: string; readonly age: number; readonly newsletter?: boolean; };
function createUser(options: CreateUserOptions): User { const {name, email, age, newsletter = false} = options; // implementation }
// GOOD: 2 args, but one is optional - use options type SendEmailOptions = { readonly to: string; readonly subject: string; readonly body?: string; };
function sendEmail(options: SendEmailOptions): void { // implementation }
// GOOD: 2 required args - no options needed function divide(numerator: number, denominator: number): number { return numerator / denominator; }
Async Functions
Always explicitly type Promise returns. Avoid async void.
// GOOD: explicit Promise return type
async function fetchUser(id: string): Promise<User> {
const response = await fetch(/api/users/${id});
return response.json();
}
// GOOD: Promise<void> for side effects async function saveUser(user: User): Promise<void> { await fetch('/api/users', { method: 'POST', body: JSON.stringify(user), }); }
// BAD: implicit return type
async function fetchUser(id: string) {
const response = await fetch(/api/users/${id});
return response.json();
}
Prefer async/await over .then() chains.
// GOOD: async/await async function processUserData(id: string): Promise<ProcessedUser> { const user = await fetchUser(id); const enriched = await enrichUserData(user); return transformUser(enriched); }
// BAD: promise chains function processUserData(id: string): Promise<ProcessedUser> { return fetchUser(id) .then(user => enrichUserData(user)) .then(enriched => transformUser(enriched)); }
When to Use Async
Be selective with async. Not everything needs to be async. Sync code is simpler to reason about and debug.
Use async for:
-
Network requests, database operations, file I/O
-
Operations that benefit from concurrent execution (Promise.all)
-
External service calls
Stay sync for:
-
Pure calculations and transformations
-
Simple data structure operations
-
Code that doesn't touch external systems
// GOOD: sync for pure transformation
function transformUser(user: User): TransformedUser {
return {
fullName: ${user.firstName} ${user.lastName},
email: user.email.toLowerCase(),
};
}
// GOOD: async for I/O async function loadAndTransformUser(id: string): Promise<TransformedUser> { const user = await fetchUser(id); return transformUser(user); // Sync call inside async function is fine }
// BAD: unnecessary async
async function transformUser(user: User): Promise<TransformedUser> {
return {
fullName: ${user.firstName} ${user.lastName},
email: user.email.toLowerCase(),
};
}
Why this matters: Async adds complexity—error propagation, cleanup, and stack traces become harder to follow. Keep the async boundary as close to the I/O as possible.
Classes
When to Use Classes
Prefer functions over classes, EXCEPT for dependency injection patterns.
// GOOD: class as dependency container class UserService { constructor( private readonly db: Database, private readonly logger: Logger, private readonly cache: Cache, ) {}
async getUser(id: string): Promise<User | null> {
this.logger.info(Fetching user ${id});
const cached = await this.cache.get(user:${id});
if (cached) return cached;
const user = await this.db.users.findById(id);
if (user) await this.cache.set(`user:${id}`, user);
return user;
} }
// BAD: class with no dependencies class MathUtils { add(a: number, b: number): number { return a + b; } }
// GOOD: plain functions function add(a: number, b: number): number { return a + b; }
Class Structure
Use constructor injection into private readonly fields.
// GOOD: constructor injection, private readonly class OrderProcessor { constructor( private readonly orderRepo: OrderRepository, private readonly paymentService: PaymentService, private readonly notifier: NotificationService, ) {}
async processOrder(orderId: string): Promise<void> { const order = await this.orderRepo.findById(orderId); // implementation } }
// BAD: public mutable fields class OrderProcessor { public orderRepo: OrderRepository; public paymentService: PaymentService;
constructor(orderRepo: OrderRepository, paymentService: PaymentService) { this.orderRepo = orderRepo; this.paymentService = paymentService; } }
The 'this' Keyword
Use this only in class methods. Avoid elsewhere.
// GOOD: this in class method class Counter { private count = 0;
increment(): void { this.count++; } }
// BAD: this in object literal const counter = { count: 0, increment() { this.count++; // fragile, breaks when passed as callback }, };
// GOOD: closure over variable function createCounter() { let count = 0; return { increment: () => count++, getCount: () => count, }; }
Type Inference
When Inference is Acceptable
Always explicit in function signatures. Infer in local variables, loops, destructuring, and intermediate calculations.
// GOOD: explicit function signature, inferred locals function processUsers(users: ReadonlyArray<User>): Array<ProcessedUser> { const results: Array<ProcessedUser> = [];
for (const user of users) { // user inferred as User const name = user.name; // name inferred as string const upper = name.toUpperCase(); // upper inferred as string const processed = {id: user.id, name: upper}; // processed inferred results.push(processed); }
return results; }
// GOOD: destructuring with inference
function formatUser({name, email}: User): string {
return ${name} <${email}>;
}
// BAD: missing return type function processUsers(users: ReadonlyArray<User>) { // ... }
// BAD: excessive annotations on locals function processUsers(users: ReadonlyArray<User>): Array<ProcessedUser> { const results: Array<ProcessedUser> = [];
for (const user: User of users) { const name: string = user.name; const upper: string = name.toUpperCase(); // ... }
return results; }
Immutability
Readonly by Default
Mark reference type parameters as Readonly<T> . Use const for all bindings unless mutation needed.
// GOOD: readonly parameters function processData( data: Readonly<UserData>, config: Readonly<ProcessConfig>, ): ProcessResult { // data and config cannot be mutated return {success: true}; }
// GOOD: const bindings function calculateTotal(items: ReadonlyArray<Item>): number { const taxRate = 0.08; const subtotal = items.reduce((sum, item) => sum + item.price, 0); const tax = subtotal * taxRate; return subtotal + tax; }
// BAD: mutable parameters function processData(data: UserData, config: ProcessConfig): ProcessResult { data.processed = true; // mutation return {success: true}; }
Arrays
ALWAYS use Array<T> or ReadonlyArray<T> . NEVER use T[] syntax.
// GOOD: Array<T> syntax const numbers: Array<number> = [1, 2, 3]; const roles: Array<UserRole> = ['admin', 'editor']; function calculateAverage(numbers: ReadonlyArray<number>): number { return numbers.reduce((a, b) => a + b, 0) / numbers.length; }
// BAD: T[] syntax (don't use this even if common in examples) const numbers: number[] = [1, 2, 3]; // NO const roles: UserRole[] = ['admin']; // NO function calculateAverage(numbers: number[]): number { // NO // ... }
Why: Consistency with other generic syntax. Array<T> is explicit and matches ReadonlyArray<T> , Record<K, V> , Promise<T> , etc. The T[] syntax is muscle memory from other languages but inconsistent with TypeScript's generic patterns.
Prefer readonly outside local scope:
// GOOD: readonly array for function parameter function calculateAverage(numbers: ReadonlyArray<number>): number { return numbers.reduce((a, b) => a + b, 0) / numbers.length; }
// GOOD: mutable array in local scope function processItems(items: ReadonlyArray<Item>): Array<ProcessedItem> { const results: Array<ProcessedItem> = []; for (const item of items) { results.push(transformItem(item)); } return results; }
Deep Immutability
Use Readonly<T> for shallow immutability, ReadonlyDeep<T> from type-fest when you need immutability all the way down.
import type {ReadonlyDeep} from 'type-fest';
// GOOD: shallow readonly for flat objects type UserData = Readonly<{ id: string; name: string; email: string; }>;
// GOOD: deep readonly for nested structures type AppConfig = ReadonlyDeep<{ database: { host: string; port: number; credentials: { username: string; password: string; }; }; features: { enabled: Array<string>; }; }>;
function loadConfig(config: AppConfig): void { // config is deeply immutable // config.database.credentials.username = 'x'; // ERROR }
Mathematics and Currency
When to Use math.js
ALWAYS use math.js for:
-
Currency calculations (money)
-
Financial calculations (interest, ROI, profit margins)
-
Precision-critical percentages
-
Complex mathematical operations requiring high precision
NEVER use JavaScript number for:
-
Money / currency amounts
-
Financial reporting calculations
-
Any calculation where precision errors are unacceptable
import { create, all, MathJsInstance } from 'mathjs';
const math: MathJsInstance = create(all);
// GOOD: math.js for currency calculations function calculateTotal( price: number, quantity: number, taxRate: number ): string { const subtotal = math.multiply( math.bignumber(price), math.bignumber(quantity) ); const tax = math.multiply(subtotal, math.bignumber(taxRate)); const total = math.add(subtotal, tax);
return math.format(total, { precision: 14 }); }
// GOOD: math.js for financial calculations function calculateROI( initialInvestment: number, finalValue: number ): string { const initial = math.bignumber(initialInvestment); const final = math.bignumber(finalValue); const difference = math.subtract(final, initial); const ratio = math.divide(difference, initial); const percentage = math.multiply(ratio, 100);
return math.format(percentage, { precision: 14 }); }
// BAD: JavaScript number for currency function calculateTotal(price: number, quantity: number, taxRate: number): number { const subtotal = price * quantity; // NO: precision errors const tax = subtotal * taxRate; // NO: compounding errors return subtotal + tax; // NO: wrong for money }
// BAD: JavaScript number for percentages in finance function calculateDiscount(price: number, discountPercent: number): number { return price * (discountPercent / 100); // NO: precision errors }
Why math.js:
-
JavaScript's native number uses IEEE 754 double-precision floating-point
-
This causes precision errors: 0.1 + 0.2 !== 0.3
-
For financial calculations, these errors are unacceptable
-
math.js BigNumber provides arbitrary precision arithmetic
When JavaScript number is OK:
-
Counters and indices
-
Simple integer math (within safe integer range)
-
Display coordinates, dimensions
-
Non-critical calculations where precision doesn't matter
Nullability
Null vs Undefined
Use null for absent values. undefined means uninitialized. Proactively coalesce to null.
// GOOD: null for absent, undefined for uninitialized type User = { name: string; email: string; phone: string | null; // may be absent };
function findUser(id: string): User | null { const user = database.users.get(id); return user ?? null; // coalesce undefined to null }
// GOOD: optional properties use ?: type UserOptions = { name: string; email: string; newsletter?: boolean; // may be undefined };
// BAD: undefined for absent values function findUser(id: string): User | undefined { // prefer null for explicit absence }
// GOOD: coalescing array access const arr: Array<number> = [1, 2, 3]; const value: number | null = arr[10] ?? null;
Enums and Unions
Prefer String Literal Unions
Avoid enums. Use string literal unions instead.
// GOOD: string literal union type Status = 'pending' | 'active' | 'complete' | 'failed';
function processStatus(status: Status): void { switch (status) { case 'pending': // handle pending break; case 'active': // handle active break; case 'complete': // handle complete break; case 'failed': // handle failed break; } }
// BAD: enum enum Status { Pending = 'pending', Active = 'active', Complete = 'complete', Failed = 'failed', }
Rationale: String literal unions are simpler, work better with discriminated unions, and don't generate runtime code.
Type Safety
Never Use 'any'
Always use unknown for truly unknown data. If a library forces any , escalate to operator for replacement.
// GOOD: unknown with type guard function parseJSON(json: string): unknown { return JSON.parse(json); }
function processData(json: string): User { const data: unknown = parseJSON(json); if (isUser(data)) { return data; } throw new Error('Invalid user data'); }
function isUser(value: unknown): value is User { return ( typeof value === 'object' && value !== null && 'name' in value && 'email' in value ); }
// BAD: using any function parseJSON(json: string): any { return JSON.parse(json); }
Type Assertions
Only for TypeScript system limitations. Always include comment explaining why.
// OK: DOM API limitation const input = document.getElementById('email') as HTMLInputElement; // DOM API returns HTMLElement, but we know it's an input
// OK: after runtime validation const data: unknown = JSON.parse(jsonString); if (isUser(data)) { const user = data; // type guard narrows to User }
// BAD: assertion without validation const user = data as User; // no runtime check
// BAD: assertion to avoid type error const value = (someValue as any) as TargetType;
Non-null Assertion (!)
Same rules as type assertions - sparingly, with justification.
// OK: after explicit check const user = users.find(u => u.id === targetId); if (user) { processUser(user); // user is non-null here, no need for ! }
// OK (with comment): known initialization pattern class Service { private connection!: Connection; // connection initialized in async init() called by constructor
constructor() { this.init(); }
private async init(): Promise<void> { this.connection = await createConnection(); } }
// BAD: hiding real potential null const value = map.get(key)!; // what if key doesn't exist?
Type Guards
Use type guards to narrow unknown types. Prefer built-in checks when possible.
// GOOD: typeof/instanceof for primitives/classes function processValue(value: unknown): string { if (typeof value === 'string') { return value.toUpperCase(); } if (typeof value === 'number') { return value.toString(); } throw new Error('Unsupported type'); }
// GOOD: custom type guard with 'is' function isUser(value: unknown): value is User { return ( typeof value === 'object' && value !== null && 'name' in value && typeof (value as any).name === 'string' && 'email' in value && typeof (value as any).email === 'string' ); }
// GOOD: discriminated union type Result = | {type: 'success'; data: string} | {type: 'error'; message: string};
function handleResult(result: Result): void { if (result.type === 'success') { console.log(result.data); // narrowed to success } else { console.error(result.message); // narrowed to error } }
// GOOD: schema validation (TypeBox preferred) import {Type, Static} from '@sinclair/typebox';
const UserSchema = Type.Object({ name: Type.String(), email: Type.String(), age: Type.Number(), });
type User = Static<typeof UserSchema>;
function validateUser(data: unknown): data is User { return Value.Check(UserSchema, data); }
Generics
Generic Constraints
Always constrain generics when possible. Use descriptive names.
// GOOD: constrained with descriptive name function mapItems<TItem, TResult>( items: ReadonlyArray<TItem>, mapper: (item: TItem) => TResult, ): Array<TResult> { return items.map(mapper); }
// GOOD: constraint on generic function getProperty<TObj extends object, TKey extends keyof TObj>( obj: TObj, key: TKey, ): TObj[TKey] { return obj[key]; }
// BAD: unconstrained, single-letter names function getProperty<T, K>(obj: T, key: K): any { return (obj as any)[key]; }
Avoid Over-Generalization
Don't make things generic unless multiple concrete types will use it.
// GOOD: specific types for single use case
function formatUser(user: User): string {
return ${user.name} <${user.email}>;
}
// BAD: unnecessary generic
function format<T extends {name: string; email: string}>(item: T): string {
return ${item.name} <${item.email}>;
}
Utility Types
Built-in vs type-fest
Use built-in utilities when available. Use type-fest for deep operations and specialized needs.
// GOOD: built-in utilities type PartialUser = Partial<User>; type RequiredUser = Required<User>; type UserKeys = keyof User; type UserValues = User[keyof User];
// GOOD: type-fest for deep operations import type {PartialDeep, RequiredDeep, ReadonlyDeep} from 'type-fest';
type DeepPartialConfig = PartialDeep<AppConfig>; type DeepRequiredConfig = RequiredDeep<AppConfig>;
Object Property Access
Use Record<K, V> for objects with dynamic keys.
// GOOD: Record for dynamic keys type UserCache = Record<string, User>;
function getUser(cache: UserCache, id: string): User | null { return cache[id] ?? null; }
// BAD: index signature type UserCache = { [key: string]: User; };
Derived Types
Use mapped types for transformations. Create explicit types for complex derivations.
// GOOD: mapped type for simple transformation type Nullable<T> = { [K in keyof T]: T[K] | null; };
type NullableUser = Nullable<User>;
// GOOD: explicit type for complex case type UserUpdateData = { name?: string; email?: string; // exclude id and other immutable fields explicitly };
// BAD: overly clever utility type usage type UserUpdateData = Omit<Partial<User>, 'id' | 'createdAt' | 'updatedAt'>;
Module Organization
Exports
Use named exports only. No default exports.
// GOOD: named exports export function processUser(user: User): ProcessedUser { // implementation }
export type ProcessedUser = { id: string; name: string; };
// BAD: default export export default function processUser(user: User): ProcessedUser { // implementation }
Barrel Exports
Use index.ts to re-export from directories.
// src/users/index.ts export * from './user-service'; export * from './user-repository'; export * from './types';
// consumers can import from directory import {UserService, type User} from './users';
Import Organization
Group by source type, alphabetize within groups. Use destructuring for fewer than 3 imports.
// GOOD: organized imports // External dependencies import {Result, ok, err} from 'neverthrow'; import type {ReadonlyDeep} from 'type-fest';
// Internal modules import {DatabaseService} from '@/services/database'; import {Logger} from '@/services/logger';
// Relative imports import {UserRepository} from './user-repository'; import type {User, UserData} from './types';
// GOOD: destructure for < 3 imports import {foo, bar} from './utils';
// GOOD: namespace for 3+ imports import * as utils from './utils'; utils.foo(); utils.bar(); utils.baz();
Note: eslint-import plugin should be configured to enforce import ordering.
FCIS Integration
Note: // pattern: comments apply only to files with runtime behavior. Type-only files, constants/enum files, barrel re-exports, tests, and generated files are exempt from classification.
Functional Core Patterns
Return Result types. Never throw exceptions. Pure functions only.
// pattern: Functional Core import {Result, ok, err} from 'neverthrow';
type ValidationError = { field: string; message: string; };
// GOOD: returns Result, pure function function validateUser( data: Readonly<UserData>, ): Result<ValidUser, ValidationError> { if (!data.email) { return err({field: 'email', message: 'Email required'}); } if (!data.name) { return err({field: 'name', message: 'Name required'}); } return ok({...data, validated: true}); }
// GOOD: transformation with Result function transformUser( user: Readonly<User>, config: Readonly<TransformConfig>, ): Result<TransformedUser, TransformError> { // pure transformation logic return ok(transformed); }
Imperative Shell Patterns
May throw exceptions. Orchestrate I/O. Minimal business logic.
// pattern: Imperative Shell import {HttpException} from './exceptions';
class UserController { constructor( private readonly userRepo: UserRepository, private readonly logger: Logger, ) {}
// GOOD: orchestrates I/O, delegates to Core, may throw async createUser(data: UserData): Promise<User> { this.logger.info('Creating user', {email: data.email});
// Delegate validation to Functional Core
const validationResult = validateUser(data);
if (validationResult.isErr()) {
throw new HttpException(400, validationResult.error.message);
}
// I/O operation
const user = await this.userRepo.create(validationResult.value);
this.logger.info('User created', {id: user.id});
return user;
} }
Compiler Configuration
Strictness
Full strict mode plus additional checks.
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true } }
All strict options are mandatory. No exceptions.
Testing
Test Type Safety
Allow type assertions in tests for test data setup.
// OK in tests: type assertions for test data const mockUser = { id: '123', name: 'Test User', } as User;
// GOOD: factory functions function createTestUser(overrides?: Partial<User>): User { return { id: '123', name: 'Test User', email: 'test@example.com', ...overrides, }; }
Tools and Libraries
Standard Stack
-
Type utilities: type-fest for deep operations and specialized utilities
-
Validation: TypeBox preferred over zod (avoid decorator-based libraries)
-
Result types: neverthrow for functional error handling
-
Linting: eslint-import for import ordering
Library Selection
When choosing between libraries, ALWAYS prefer the one without decorators.
// AVOID: decorator-based libraries import {IsEmail, IsString} from 'class-validator';
class CreateUserDto { @IsString() name: string;
@IsEmail() email: string; }
// PREFER: schema-based validation import {Type} from '@sinclair/typebox';
const CreateUserSchema = Type.Object({ name: Type.String(), email: Type.String({format: 'email'}), });
Documentation
JSDoc for Public APIs
Use JSDoc comments for exported functions and types.
/**
- Processes user data and returns a validated user object.
- @param data - Raw user data to process
- @returns Result containing validated user or validation error */ export function validateUser( data: Readonly<UserData>, ): Result<ValidUser, ValidationError> { // implementation }
/**
- Configuration options for user processing. / export type ProcessUserOptions = { /* User's full name / readonly name: string; /* User's email address / readonly email: string; /* Whether to send welcome email (default: true) */ readonly sendWelcome?: boolean; };
Abstraction Guidelines
When to Abstract
Follow rule of three. Abstract when types become complex (3+ properties/levels).
// GOOD: abstract after third repetition // First use const user1 = {id: '1', name: 'Alice', email: 'alice@example.com'};
// Second use const user2 = {id: '2', name: 'Bob', email: 'bob@example.com'};
// Third use - now abstract type User = { id: string; name: string; email: string; };
// GOOD: abstract complex inline types // Before function process(data: { user: {name: string; email: string}; settings: {theme: string; notifications: boolean}; }): void {}
// After - extract when > 3 properties or nested type UserInfo = { name: string; email: string; };
type UserSettings = { theme: string; notifications: boolean; };
type ProcessData = { user: UserInfo; settings: UserSettings; };
function process(data: Readonly<ProcessData>): void {}
Sharp Edges
Runtime hazards that TypeScript doesn't catch. Know these cold.
Equality
Always use === . Never use == .
// BAD: loose equality has surprising coercion "0" == false; // true [] == ![]; // true null == undefined; // true
// GOOD: strict equality "0" === false; // false [] === ![]; // false null === undefined; // false
TypeScript won't save you here—both are valid syntax.
Prototype Pollution
Never merge untrusted objects into plain objects.
// DANGEROUS: merging user input const userInput = JSON.parse('{"proto": {"isAdmin": true}}'); Object.assign({}, userInput); // pollutes Object.prototype
// SAFE: use Map for dynamic keys from untrusted sources const safeStore = new Map<string, unknown>(); safeStore.set(key, value);
// SAFE: null-prototype object const safeObj = Object.create(null) as Record<string, unknown>;
// SAFE: validate keys before merge function safeMerge<T extends object>(target: T, source: unknown): T { if (typeof source !== 'object' || source === null) return target; for (const key of Object.keys(source)) { if (key === 'proto' || key === 'constructor' || key === 'prototype') { continue; // skip dangerous keys } (target as Record<string, unknown>)[key] = (source as Record<string, unknown>)[key]; } return target; }
Regular Expression DoS (ReDoS)
Avoid nested quantifiers and overlapping alternatives.
// DANGEROUS: catastrophic backtracking const bad1 = /(a+)+$/; // nested quantifiers const bad2 = /(a|a)+$/; // overlapping alternatives const bad3 = /(\w+)*$/; // greedy quantifier in group with quantifier
// These can freeze the event loop on crafted input like "aaaaaaaaaaaaaaaaaaaaaaaa!"
// SAFER: avoid nesting, use possessive-like patterns const safer = /a+$/; // no nesting const safest = /^[a-z]+$/; // anchored, simple character class
When accepting user-provided regex patterns, use a timeout or run in a worker.
parseInt Radix
Always specify the radix parameter.
// BAD: radix varies by engine/input parseInt("08"); // 0 or 8 depending on engine parseInt("0x10"); // 16 (hex prefix always recognized)
// GOOD: explicit radix parseInt("08", 10); // 8 parseInt("10", 16); // 16 parseInt("1010", 2); // 10
// BETTER: use Number() for decimal Number("08"); // 8 Number.parseInt("08", 10); // 8
Array Mutations
Know which methods mutate in place.
Mutates Returns new array
.sort()
.toSorted() (ES2023)
.reverse()
.toReversed() (ES2023)
.splice()
.toSpliced() (ES2023)
.push() , .pop()
.concat() , .slice()
.shift() , .unshift()
spread: [first, ...rest]
.fill()
// BAD: mutates original const original = [3, 1, 2]; const sorted = original.sort(); // original is now [1, 2, 3]
// GOOD: copy first (pre-ES2023) const sorted = [...original].sort(); const sorted = original.slice().sort();
// GOOD: use non-mutating methods (ES2023+) const sorted = original.toSorted(); const reversed = original.toReversed();
Numeric Sort
Default sort is lexicographic, not numeric.
// WRONG: sorts as strings [10, 2, 1].sort(); // [1, 10, 2]
// CORRECT: numeric comparator [10, 2, 1].sort((a, b) => a - b); // [1, 2, 10]
// Descending [10, 2, 1].sort((a, b) => b - a); // [10, 2, 1]
eval and Function Constructor
Never use eval() or new Function() with untrusted input.
// DANGEROUS: code injection eval(userInput); // arbitrary code execution new Function('return ' + userInput)(); // same risk
// If you need dynamic evaluation, use a sandboxed environment or parser
JSON Precision Loss
JSON.parse loses precision for large integers and BigInt.
// PROBLEM: JavaScript numbers lose precision > 2^53 JSON.parse('{"id": 9007199254740993}'); // id becomes 9007199254740992
// PROBLEM: BigInt not supported JSON.parse('{"value": 123n}'); // SyntaxError
// SOLUTION: use string representation for large IDs type ApiResponse = { id: string; // "9007199254740993" - keep as string };
// SOLUTION: use a BigInt-aware parser for financial data // Or use string fields and parse with BigInt() after
Promise.all vs Promise.allSettled
Promise.all fails fast; Promise.allSettled waits for all.
// Promise.all: rejects immediately on first failure // Use when: all must succeed, fail fast is desired async function fetchAllRequired(ids: ReadonlyArray<string>): Promise<Array<User>> { const promises = ids.map(id => fetchUser(id)); return Promise.all(promises); // throws on first failure }
// Promise.allSettled: waits for all, never rejects // Use when: need results from successful ones even if some fail async function fetchAllBestEffort( ids: ReadonlyArray<string>, ): Promise<Array<User>> { const promises = ids.map(id => fetchUser(id)); const results = await Promise.allSettled(promises);
return results .filter((r): r is PromiseFulfilledResult<User> => r.status === 'fulfilled') .map(r => r.value); }
// Common patterns with allSettled const results = await Promise.allSettled(promises);
const succeeded = results.filter(r => r.status === 'fulfilled'); const failed = results.filter(r => r.status === 'rejected');
// Log failures, return successes for (const failure of failed) { if (failure.status === 'rejected') { logger.error('Operation failed', {reason: failure.reason}); } }
Method Behavior Use when
Promise.all
Rejects on first failure All must succeed
Promise.allSettled
Always resolves with status array Need partial results
Promise.race
Resolves/rejects with first to complete Timeout patterns
Promise.any
Resolves with first success, rejects if all fail First success wins
Unsafe Property Access
Bracket notation with user input is dangerous.
// DANGEROUS: arbitrary property access function getValue(obj: object, key: string): unknown { return (obj as Record<string, unknown>)[key]; // could access proto, constructor }
// SAFER: validate or use Map function safeGetValue(obj: Record<string, unknown>, key: string): unknown { if (!Object.hasOwn(obj, key)) return undefined; if (key === 'proto' || key === 'constructor') return undefined; return obj[key]; }
Common Mistakes
Mistake Fix
Using interface for data shapes Use type instead
Using any in business logic Use unknown
- type guards
const foo = () => {} top-level declarations Use function foo() {}
Type assertions without validation Add runtime validation or type guard
Mutable parameters Mark as Readonly<T> for reference types
undefined for absent values Use null ; coalesce with ?? null
Enums Use string literal unions
Missing return types on exports Always type function returns
Using T[] for arrays Use Array<T> or ReadonlyArray<T>
JavaScript number for money/currency Use math.js with BigNumber
Decorators (unless framework requires) Use functions or type-based solutions
Default exports Use named exports only
Over-abstraction before third use Wait for pattern to emerge
Title Case error messages Use lowercase fragments: failed to connect: timeout
Unnecessary async on pure functions Keep sync unless I/O is involved
== for comparisons Use === always
parseInt() without radix Use parseInt(str, 10) or Number()
.sort() on numeric arrays without comparator Use .sort((a, b) => a - b)
Object.assign() with untrusted input Validate keys or use Map
Nested regex quantifiers (a+)+
Refactor to avoid ReDoS
Promise.all when partial results acceptable Use Promise.allSettled
Red Flags
STOP and refactor when you see:
-
any keyword in business logic
-
interface for data shapes (not class contracts)
-
JavaScript number for money, currency, or financial calculations
-
T[] instead of Array<T> syntax
-
Decorators in library selection
-
Type assertions without explanatory comments
-
Missing return types on exported functions
-
Mutable class fields (should be readonly )
-
undefined used for explicitly absent values
-
Enums instead of string literal unions
-
Default exports
-
Functions with 4+ positional parameters
-
Complex inline types used repeatedly
-
Async functions that don't perform I/O
-
Error messages in Title Case
-
== instead of ===
-
eval() or new Function() with any dynamic input
-
Regex patterns with nested quantifiers (x+)+ or (x|x)+
-
Object.assign() or spread with user-controlled objects
-
parseInt() without explicit radix
-
.sort() on numbers without comparator function
-
JSON.parse() on data with large integer IDs (use string IDs)
Reference
For comprehensive type-fest utilities documentation, see type-fest.md.
For comprehensive TypeBox validator documentation, see typebox.md. Please note that we generally use AJV as the canonical validator, but TypeBox is the schema generator.