
Typescript Best Practices
- 555 installs
- 133 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
typescript-best-practices is a code-quality agent skill that guides Claude, Cursor, and other agents to write clean, idiomatic, maintainable TypeScript for developers who want to avoid common anti-patterns in generated c
About
typescript-best-practices is a jwynia/agent-skills reference with 494 installs on skills.sh and a rank of 21 that encodes idiomatic TypeScript conventions for agent-assisted development. The skill teaches agents to prefer precise types, safe narrowing, consistent module boundaries, and maintainable patterns while rejecting frequent anti-patterns like excessive any, improper async handling, and leaky abstractions. Developers reach for typescript-best-practices when reviewing agent output on React, Node, or full-stack TypeScript repos, or when setting project guardrails before large refactors. It functions as a standing style and quality checker rather than a test runner or formatter. Use it whenever TypeScript files are authored or edited by agents and long-term maintainability matters more than fastest first draft.
- Enforces official TypeScript style, strict mode, and modern syntax rules
- Prevents common JavaScript-in-TypeScript mistakes that break type safety
- Works as reusable agent capability across frontend, backend, and full-stack agents
- Reduces debugging time by catching issues before code runs
- Compatible with any agent that can read and follow structured rule sets
Typescript Best Practices by the numbers
- 555 all-time installs (skills.sh)
- +7 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,658 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/agent-skills --skill typescript-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 555 |
|---|---|
| repo stars | ★ 133 |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
How do agents write idiomatic TypeScript code?
Make Claude, Cursor, and other agents produce clean, idiomatic, and maintainable TypeScript code without common anti-patterns.
Who is it for?
TypeScript developers using Claude or Cursor who want agent output to match team quality standards and avoid recurring anti-patterns.
Skip if: Pure JavaScript codebases or teams that rely solely on ESLint autofix without agent-authored TypeScript.
When should I use this skill?
An agent is authoring or refactoring .ts or .tsx files and output must follow idiomatic TypeScript instead of sloppy defaults.
What you get
TypeScript source files following idiomatic patterns with reduced any usage and maintainable module structure.
- Idiomatic TypeScript source edits
- Reduced anti-pattern patterns
By the numbers
- 494 installs on skills.sh
- skills.sh rank 21
Files
TypeScript Best Practices
Guide AI agents in writing high-quality TypeScript code. This skill provides coding standards, architecture patterns, and tools for analysis and scaffolding.
When to Use This Skill
Use this skill when:
- Generating new TypeScript code
- Reviewing TypeScript files for quality issues
- Creating new modules, services, or components
- Refactoring JavaScript to TypeScript
- Answering questions about TypeScript patterns or types
- Designing APIs or interfaces
Do NOT use this skill when:
- Working with pure JavaScript (no TypeScript)
- Debugging runtime errors (use debugging tools)
- Framework-specific patterns (React, Vue, etc. - use framework skills)
Core Principles
1. Type Safety First
Maximize compile-time error detection:
// Prefer unknown over any for unknown types
function processInput(data: unknown): string {
if (typeof data === "string") return data;
if (typeof data === "number") return String(data);
throw new Error("Unsupported type");
}
// Explicit return types for public APIs
export function calculateTotal(items: ReadonlyArray<Item>): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
// Use const assertions for literal types
const CONFIG = {
mode: "production",
version: 1,
} as const;2. Immutability by Default
Prevent accidental mutations:
// Use readonly for object properties
interface User {
readonly id: string;
readonly email: string;
name: string; // Only mutable if intentional
}
// Use ReadonlyArray for collections
function processItems(items: ReadonlyArray<Item>): ReadonlyArray<Result> {
return items.map(transform);
}
// Prefer spreading over mutation
function updateUser(user: User, name: string): User {
return { ...user, name };
}3. Error Handling with Types
Use the type system for error handling:
// Result type for recoverable errors
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
// Typed error classes
class ValidationError extends Error {
constructor(
message: string,
readonly field: string,
readonly code: string
) {
super(message);
this.name = "ValidationError";
}
}
// Function with Result return type
function parseConfig(input: string): Result<Config, ValidationError> {
try {
const data = JSON.parse(input);
if (!isValidConfig(data)) {
return {
success: false,
error: new ValidationError("Invalid config", "root", "INVALID_FORMAT"),
};
}
return { success: true, value: data };
} catch {
return {
success: false,
error: new ValidationError("Parse failed", "root", "PARSE_ERROR"),
};
}
}4. Code Organization
Structure code for maintainability:
// One concept per file
// user.ts - User type and related utilities
export interface User {
readonly id: string;
readonly email: string;
readonly createdAt: Date;
}
export function createUser(email: string): User {
return {
id: crypto.randomUUID(),
email,
createdAt: new Date(),
};
}
// Explicit exports (no barrel file wildcards)
// index.ts
export { User, createUser } from "./user.ts";
export { validateEmail } from "./validation.ts";Quick Reference
| Category | Prefer | Avoid |
|---|---|---|
| Unknown types | unknown | any |
| Collections | ReadonlyArray<T> | T[] for inputs |
| Objects | Readonly<T> | Mutable by default |
| Null checks | Optional chaining ?. | != null |
| Type narrowing | Type guards | as assertions |
| Return types | Explicit on exports | Inferred on exports |
| Enums | String literal unions | Numeric enums |
| Imports | Named imports | Default imports |
| Errors | Result types | Throwing for flow control |
| Loops | for...of, .map() | for...in on arrays |
Code Generation Guidelines
When generating TypeScript code, follow these patterns:
Module Structure
/**
* Module description
* @module module-name
*/
// === Types ===
export interface ModuleOptions {
readonly setting: string;
}
export interface ModuleResult {
readonly data: unknown;
}
// === Constants ===
const DEFAULT_OPTIONS: ModuleOptions = {
setting: "default",
};
// === Implementation ===
export function processData(
input: unknown,
options: Partial<ModuleOptions> = {}
): ModuleResult {
const opts = { ...DEFAULT_OPTIONS, ...options };
// Implementation
return { data: input };
}Function Design
// Pure functions preferred
function transform(input: Input): Output {
// No side effects, same input = same output
return { ...input, processed: true };
}
// Explicit parameter types
function fetchUser(id: string, options?: FetchOptions): Promise<User> {
// Implementation
}
// Use function overloads for complex signatures
function parse(input: string): ParsedData;
function parse(input: Buffer): ParsedData;
function parse(input: string | Buffer): ParsedData {
// Implementation
}Interface Design
// Prefer interfaces for object shapes
interface UserData {
readonly id: string;
readonly email: string;
}
// Use type for unions and intersections
type UserRole = "admin" | "user" | "guest";
type AdminUser = UserData & { readonly role: "admin" };
// Document with JSDoc
/**
* Configuration for the API client
* @property baseUrl - The base URL for API requests
* @property timeout - Request timeout in milliseconds
*/
interface ApiConfig {
readonly baseUrl: string;
readonly timeout?: number;
}Common Anti-Patterns
Avoid these patterns when generating code:
| Anti-Pattern | Problem | Solution |
|---|---|---|
any type | Disables type checking | Use unknown and narrow |
as assertions | Runtime errors | Use type guards |
Non-null ! | Null pointer errors | Optional chaining ?. |
| Mutable params | Unexpected mutations | Readonly<T> |
| Magic strings | Typos, no autocomplete | String literal types |
| God classes | Hard to test/maintain | Single responsibility |
| Circular deps | Build/runtime issues | Dependency inversion |
| Index signatures | Lose type info | Explicit properties |
See references/anti-patterns/common-mistakes.md for detailed examples.
Scripts Reference
analyze.ts
Analyze TypeScript code for quality issues:
deno run --allow-read scripts/analyze.ts <path> [options]
Options:
--strict Enable all checks
--json Output JSON for programmatic use
--fix-hints Show suggested fixes
Examples:
# Analyze a file
deno run --allow-read scripts/analyze.ts ./src/utils.ts
# Analyze directory with strict mode
deno run --allow-read scripts/analyze.ts ./src --strict
# JSON output for CI
deno run --allow-read scripts/analyze.ts ./src --jsongenerate-types.ts
Generate TypeScript types from JSON data:
deno run --allow-read --allow-write scripts/generate-types.ts <input> [options]
Options:
--name <name> Root type name (default: inferred)
--output <path> Output file path
--readonly Generate readonly types
--interface Use interface instead of type
Examples:
# Generate from JSON file
deno run --allow-read scripts/generate-types.ts ./data.json --name Config
# Generate readonly interface
deno run --allow-read --allow-write scripts/generate-types.ts ./api-response.json \
--interface --readonly --output ./types/api.tsscaffold-module.ts
Create properly structured TypeScript modules:
deno run --allow-read --allow-write scripts/scaffold-module.ts [options]
Options:
--name <name> Module name (required)
--path <path> Target directory (default: ./src)
--type <type> Type: service, util, component
--with-tests Include test file
Examples:
# Create a utility module
deno run --allow-read --allow-write scripts/scaffold-module.ts \
--name "string-utils" --type util
# Create a service with tests
deno run --allow-read --allow-write scripts/scaffold-module.ts \
--name "user-service" --type service --with-testsAdditional Resources
Type System Deep Dives
references/type-system/advanced-types.md- Generics, conditional types, mapped typesreferences/type-system/type-guards.md- Type narrowing techniquesreferences/type-system/utility-types.md- Built-in utility types
Pattern Guides
references/patterns/error-handling.md- Result types, typed errorsreferences/patterns/async-patterns.md- Async/await best practicesreferences/patterns/functional-patterns.md- Immutability, compositionreferences/patterns/module-patterns.md- Exports, dependency injection
Architecture
references/architecture/project-structure.md- Directory organizationreferences/architecture/api-design.md- Interface design, versioning
Templates
assets/templates/module-template.ts.md- Module starter templateassets/templates/service-template.ts.md- Service class templateassets/tsconfig-presets/strict.json- Maximum strictness configassets/tsconfig-presets/recommended.json- Balanced defaults
Module Template
Starter template for a TypeScript utility module with proper types and structure.
Template
/**
* ${ModuleName}
*
* ${description}
*
* @module ${module-name}
*/
// === Types ===
/**
* Configuration options for ${moduleName}
*/
export interface ${ModuleName}Options {
/**
* Description of option
* @default defaultValue
*/
readonly option1?: string;
/**
* Another option
*/
readonly option2?: number;
}
/**
* Result of ${moduleName} operations
*/
export interface ${ModuleName}Result<T> {
readonly success: boolean;
readonly data?: T;
readonly error?: ${ModuleName}Error;
}
/**
* Error information
*/
export interface ${ModuleName}Error {
readonly code: string;
readonly message: string;
}
// === Constants ===
/**
* Default options
*/
const DEFAULT_OPTIONS: Required<${ModuleName}Options> = {
option1: "default",
option2: 0,
};
// === Implementation ===
/**
* Main function for ${moduleName}
*
* @param input - The input to process
* @param options - Configuration options
* @returns Result containing processed data or error
*
* @example
* ```typescript
* import { ${functionName} } from "./${module-name}";
*
* const result = ${functionName}("input");
* if (result.success) {
* console.log(result.data);
* }
* ```
*/
export function ${functionName}<T>(
input: T,
options: Partial<${ModuleName}Options> = {}
): ${ModuleName}Result<T> {
const opts = { ...DEFAULT_OPTIONS, ...options };
try {
// TODO: Implement logic
return {
success: true,
data: input,
};
} catch (error) {
return {
success: false,
error: {
code: "PROCESSING_ERROR",
message: error instanceof Error ? error.message : String(error),
},
};
}
}
/**
* Type guard for validating input
*
* @param value - Value to validate
* @returns True if value is valid input
*/
export function isValid${ModuleName}Input(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
// === Exports ===
export type {
${ModuleName}Options,
${ModuleName}Result,
${ModuleName}Error,
};Usage
1. Copy the template to your project 2. Replace placeholders:
${ModuleName}- PascalCase name (e.g.,StringUtils)${moduleName}- camelCase name (e.g.,stringUtils)${module-name}- kebab-case name (e.g.,string-utils)${functionName}- Main function name (e.g.,processString)${description}- Module description
3. Implement the logic in the main function 4. Add additional functions as needed 5. Update types to match your domain
Service Template
Starter template for a TypeScript service class with dependency injection, error handling, and proper types.
Template
/**
* ${ServiceName} Service
*
* Handles ${domain}-related operations.
*
* @module ${service-name}
*/
// === Types ===
/**
* Configuration for ${ServiceName}Service
*/
export interface ${ServiceName}Config {
/**
* Base URL for API requests
*/
readonly baseUrl?: string;
/**
* Request timeout in milliseconds
* @default 30000
*/
readonly timeout?: number;
/**
* Enable debug logging
* @default false
*/
readonly debug?: boolean;
}
/**
* Entity managed by this service
*/
export interface ${EntityName} {
readonly id: string;
readonly createdAt: Date;
readonly updatedAt: Date;
// Add entity-specific fields
}
/**
* Input for creating a new entity
*/
export type Create${EntityName}Input = Omit<${EntityName}, "id" | "createdAt" | "updatedAt">;
/**
* Input for updating an entity
*/
export type Update${EntityName}Input = Partial<Create${EntityName}Input>;
/**
* Query parameters for listing entities
*/
export interface ${EntityName}Query {
readonly limit?: number;
readonly offset?: number;
readonly orderBy?: keyof ${EntityName};
readonly order?: "asc" | "desc";
}
/**
* Result type for service operations
*/
export type ${ServiceName}Result<T> =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: ${ServiceName}Error };
/**
* Error type for service operations
*/
export class ${ServiceName}Error extends Error {
constructor(
message: string,
readonly code: ${ServiceName}ErrorCode,
readonly cause?: unknown
) {
super(message);
this.name = "${ServiceName}Error";
}
}
/**
* Error codes for this service
*/
export type ${ServiceName}ErrorCode =
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "DUPLICATE"
| "UNAUTHORIZED"
| "INTERNAL_ERROR";
// === Dependencies ===
/**
* Logger interface
*/
interface Logger {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
/**
* Repository interface
*/
interface ${EntityName}Repository {
findById(id: string): Promise<${EntityName} | null>;
findAll(query?: ${EntityName}Query): Promise<ReadonlyArray<${EntityName}>>;
create(input: Create${EntityName}Input): Promise<${EntityName}>;
update(id: string, input: Update${EntityName}Input): Promise<${EntityName} | null>;
delete(id: string): Promise<boolean>;
}
// === Constants ===
const DEFAULT_CONFIG: Required<${ServiceName}Config> = {
baseUrl: "",
timeout: 30000,
debug: false,
};
// === Service Implementation ===
/**
* Service for managing ${entityName} operations
*
* @example
* ```typescript
* const service = create${ServiceName}Service({
* repository,
* logger,
* config: { debug: true },
* });
*
* const result = await service.getById("123");
* if (result.success) {
* console.log(result.data);
* }
* ```
*/
export class ${ServiceName}Service {
private readonly config: Required<${ServiceName}Config>;
constructor(
private readonly repository: ${EntityName}Repository,
private readonly logger: Logger,
config: Partial<${ServiceName}Config> = {}
) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Get entity by ID
*/
async getById(id: string): Promise<${ServiceName}Result<${EntityName}>> {
this.log("debug", "Getting entity by ID", { id });
try {
const entity = await this.repository.findById(id);
if (!entity) {
return this.failure("NOT_FOUND", `${EntityName} not found: ${id}`);
}
return this.success(entity);
} catch (error) {
return this.handleError(error, "Failed to get entity");
}
}
/**
* Get all entities
*/
async getAll(
query?: ${EntityName}Query
): Promise<${ServiceName}Result<ReadonlyArray<${EntityName}>>> {
this.log("debug", "Getting all entities", { query });
try {
const entities = await this.repository.findAll(query);
return this.success(entities);
} catch (error) {
return this.handleError(error, "Failed to get entities");
}
}
/**
* Create new entity
*/
async create(
input: Create${EntityName}Input
): Promise<${ServiceName}Result<${EntityName}>> {
this.log("debug", "Creating entity", { input });
try {
// TODO: Add validation
const entity = await this.repository.create(input);
this.log("info", "Entity created", { id: entity.id });
return this.success(entity);
} catch (error) {
return this.handleError(error, "Failed to create entity");
}
}
/**
* Update existing entity
*/
async update(
id: string,
input: Update${EntityName}Input
): Promise<${ServiceName}Result<${EntityName}>> {
this.log("debug", "Updating entity", { id, input });
try {
const entity = await this.repository.update(id, input);
if (!entity) {
return this.failure("NOT_FOUND", `${EntityName} not found: ${id}`);
}
this.log("info", "Entity updated", { id });
return this.success(entity);
} catch (error) {
return this.handleError(error, "Failed to update entity");
}
}
/**
* Delete entity by ID
*/
async delete(id: string): Promise<${ServiceName}Result<void>> {
this.log("debug", "Deleting entity", { id });
try {
const deleted = await this.repository.delete(id);
if (!deleted) {
return this.failure("NOT_FOUND", `${EntityName} not found: ${id}`);
}
this.log("info", "Entity deleted", { id });
return this.success(undefined);
} catch (error) {
return this.handleError(error, "Failed to delete entity");
}
}
// === Private Helpers ===
private success<T>(data: T): ${ServiceName}Result<T> {
return { success: true, data };
}
private failure(
code: ${ServiceName}ErrorCode,
message: string,
cause?: unknown
): ${ServiceName}Result<never> {
return {
success: false,
error: new ${ServiceName}Error(message, code, cause),
};
}
private handleError(error: unknown, message: string): ${ServiceName}Result<never> {
this.log("error", message, { error });
return this.failure("INTERNAL_ERROR", message, error);
}
private log(
level: "debug" | "info" | "error",
message: string,
context?: Record<string, unknown>
): void {
if (level === "debug" && !this.config.debug) return;
this.logger[level](`[${ServiceName}Service] ${message}`, context);
}
}
// === Factory Function ===
interface ${ServiceName}ServiceDeps {
readonly repository: ${EntityName}Repository;
readonly logger: Logger;
readonly config?: Partial<${ServiceName}Config>;
}
/**
* Create a new ${ServiceName}Service instance
*/
export function create${ServiceName}Service(
deps: ${ServiceName}ServiceDeps
): ${ServiceName}Service {
return new ${ServiceName}Service(deps.repository, deps.logger, deps.config);
}Usage
1. Copy the template to your project 2. Replace placeholders:
${ServiceName}- PascalCase service name (e.g.,User)${EntityName}- PascalCase entity name (e.g.,User)${entityName}- camelCase entity name (e.g.,user)${service-name}- kebab-case name (e.g.,user-service)${domain}- Domain description (e.g.,user management)
3. Add entity-specific fields to the interface 4. Implement repository with your data source 5. Add validation logic as needed 6. Extend error codes for your domain
{
"$schema": "https://json.schemastore.org/tsconfig",
"_comment": "Balanced TypeScript configuration for most projects",
"compilerOptions": {
// === Language and Environment ===
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
// === Strict Type Checking ===
// Core strict mode (recommended minimum)
"strict": true,
// Additional checks (enable as project matures)
"noUncheckedIndexedAccess": false,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"exactOptionalPropertyTypes": false,
// === Error Prevention ===
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// === Module Handling ===
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowSyntheticDefaultImports": true,
// === Output ===
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
// === Other ===
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
{
"$schema": "https://json.schemastore.org/tsconfig",
"_comment": "Maximum strictness TypeScript configuration",
"compilerOptions": {
// === Language and Environment ===
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
// === Strict Type Checking (ALL enabled) ===
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
// === Additional Strict Checks ===
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
// === Error Prevention ===
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
// === Module Handling ===
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowSyntheticDefaultImports": true,
// === Output ===
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
// === Other ===
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmitOnError": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
Common Mistakes Reference
Quick reference for the most frequent TypeScript errors. Each entry shows the wrong pattern and the correct fix.
Type Safety Errors
Using any Type
// WRONG: any disables type checking
function processData(data: any): any {
return data.value.toUpperCase();
}
// CORRECT: Use unknown and narrow
function processData(data: unknown): string {
if (typeof data === "object" && data !== null && "value" in data) {
const value = (data as { value: unknown }).value;
if (typeof value === "string") {
return value.toUpperCase();
}
}
throw new Error("Invalid data format");
}
// CORRECT: Define specific type
interface DataInput {
readonly value: string;
}
function processData(data: DataInput): string {
return data.value.toUpperCase();
}Non-Null Assertion
// WRONG: Non-null assertion can cause runtime errors
function getUser(id: string): User {
const user = users.find(u => u.id === id);
return user!; // Crashes if user not found
}
// CORRECT: Handle the null case
function getUser(id: string): User | null {
return users.find(u => u.id === id) ?? null;
}
// CORRECT: Throw descriptive error
function getUser(id: string): User {
const user = users.find(u => u.id === id);
if (!user) {
throw new Error(`User not found: ${id}`);
}
return user;
}Type Assertions Instead of Guards
// WRONG: Type assertion bypasses type checking
function handleEvent(event: unknown) {
const mouseEvent = event as MouseEvent;
console.log(mouseEvent.clientX); // Crashes if not MouseEvent
}
// CORRECT: Use type guard
function isMouseEvent(event: unknown): event is MouseEvent {
return event instanceof MouseEvent;
}
function handleEvent(event: unknown) {
if (isMouseEvent(event)) {
console.log(event.clientX); // Safe
}
}Implicit Any in Callbacks
// WRONG: Parameter has implicit any
const doubled = numbers.map(n => n * 2); // n is any if noImplicitAny is off
// CORRECT: Explicit parameter type
const doubled = numbers.map((n: number) => n * 2);
// CORRECT: Type the array properly
const numbers: number[] = [1, 2, 3];
const doubled = numbers.map(n => n * 2); // n is inferred as numberObject and Array Errors
Mutable Parameters
// WRONG: Function can mutate input
function addItem(items: string[], item: string): string[] {
items.push(item); // Mutates original array
return items;
}
// CORRECT: Return new array
function addItem(items: ReadonlyArray<string>, item: string): string[] {
return [...items, item];
}
// CORRECT: Use readonly parameter
function processItems(items: readonly string[]): void {
// items.push("x"); // Error: cannot mutate
}Index Access Without Checks
// WRONG: Assumes element exists
function getFirst<T>(arr: T[]): T {
return arr[0]; // Could be undefined
}
// CORRECT: With noUncheckedIndexedAccess
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
// CORRECT: Assert non-empty
function getFirst<T>(arr: [T, ...T[]]): T {
return arr[0]; // Tuple guarantees at least one element
}Object Spread Overwriting
// WRONG: Later properties overwrite earlier ones
const config = {
...defaults,
host: userConfig.host, // undefined overwrites default
};
// CORRECT: Filter undefined values
const config = {
...defaults,
...(userConfig.host !== undefined && { host: userConfig.host }),
};
// CORRECT: Use nullish coalescing
const config = {
...defaults,
host: userConfig.host ?? defaults.host,
};Function Errors
Missing Return Type on Public APIs
// WRONG: Return type is inferred (can change accidentally)
export function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
// CORRECT: Explicit return type
export function calculateTotal(items: ReadonlyArray<Item>): number {
return items.reduce((sum, item) => sum + item.price, 0);
}Async Function Without Error Handling
// WRONG: Error silently ignored
async function fetchData() {
const response = await fetch("/api/data");
return response.json(); // What if fetch fails?
}
// CORRECT: Handle errors
async function fetchData(): Promise<Result<Data, FetchError>> {
try {
const response = await fetch("/api/data");
if (!response.ok) {
return err({ type: "http", status: response.status });
}
return ok(await response.json());
} catch (error) {
return err({ type: "network", message: String(error) });
}
}Floating Promises
// WRONG: Promise not awaited or handled
function saveData(data: Data) {
api.save(data); // Fire and forget - errors lost
}
// CORRECT: Await the promise
async function saveData(data: Data): Promise<void> {
await api.save(data);
}
// CORRECT: Handle with .catch if truly fire-and-forget
function saveData(data: Data): void {
api.save(data).catch(error => {
logger.error("Failed to save", error);
});
}Null and Undefined Errors
Loose Null Checks
// WRONG: Doesn't distinguish null from undefined or empty
function process(value: string | null | undefined) {
if (value) {
return value.toUpperCase();
}
return "default"; // Empty string "" returns "default"
}
// CORRECT: Explicit null check
function process(value: string | null | undefined) {
if (value !== null && value !== undefined) {
return value.toUpperCase();
}
return "default";
}
// CORRECT: Use nullish coalescing
function process(value: string | null | undefined) {
return (value ?? "default").toUpperCase();
}Optional Chaining Misuse
// WRONG: Continues execution with undefined
const userName = user?.profile?.name;
console.log(userName.toUpperCase()); // Error if undefined
// CORRECT: Handle the undefined case
const userName = user?.profile?.name;
if (userName) {
console.log(userName.toUpperCase());
}
// CORRECT: Provide default
const userName = user?.profile?.name ?? "Anonymous";
console.log(userName.toUpperCase());Enum and Union Errors
Numeric Enums
// WRONG: Numeric enums have surprising behavior
enum Status {
Active, // 0
Inactive, // 1
Deleted, // 2
}
const status: Status = 999; // No error!
// CORRECT: String literal union
type Status = "active" | "inactive" | "deleted";
const status: Status = "invalid"; // Error!
// CORRECT: Const enum if you need enum-like syntax
const Status = {
Active: "active",
Inactive: "inactive",
Deleted: "deleted",
} as const;
type Status = typeof Status[keyof typeof Status];Missing Exhaustive Check
// WRONG: Missing case in switch
type Action = "create" | "update" | "delete";
function handleAction(action: Action) {
switch (action) {
case "create":
return create();
case "update":
return update();
// "delete" case missing - no error!
}
}
// CORRECT: Exhaustive check
function handleAction(action: Action) {
switch (action) {
case "create":
return create();
case "update":
return update();
case "delete":
return remove();
default:
const _exhaustive: never = action;
throw new Error(`Unknown action: ${_exhaustive}`);
}
}Class Errors
Public By Default
// WRONG: Everything is public by default
class UserService {
db: Database; // Exposed!
cache: Cache; // Exposed!
constructor(db: Database, cache: Cache) {
this.db = db;
this.cache = cache;
}
}
// CORRECT: Explicit visibility
class UserService {
constructor(
private readonly db: Database,
private readonly cache: Cache
) {}
}Missing Override
// WRONG: Accidental override (or not)
class Animal {
speak() { console.log("..."); }
}
class Dog extends Animal {
speak() { console.log("Woof"); } // Is this intentional?
}
// CORRECT: Explicit override
class Dog extends Animal {
override speak() { console.log("Woof"); }
}Import Errors
Default Imports
// WRONG: Default imports are harder to refactor
import User from "./user";
import config from "./config";
// CORRECT: Named imports
import { User } from "./user";
import { config } from "./config";
// Exception: When importing external libraries that use defaults
import React from "react";Type-Only Imports
// WRONG: Importing types as values
import { User, createUser } from "./user";
function process(user: User) { ... }
// CORRECT: Separate type imports (tree-shaking friendly)
import type { User } from "./user";
import { createUser } from "./user";
// Or inline
import { type User, createUser } from "./user";Quick Reference Table
| Anti-Pattern | Problem | Solution |
|---|---|---|
any type | No type checking | Use unknown + type guards |
! non-null | Runtime crashes | Null checks or Result types |
as Type | Bypasses checks | Type guards |
| Mutable params | Side effects | readonly / spread |
| Missing return type | Fragile API | Explicit return types |
| Floating promises | Lost errors | await or .catch() |
if (value) | Excludes 0, "" | !== null / !== undefined |
| Numeric enums | Accepts any number | String unions |
| No exhaustive check | Missing cases | never in default |
| Default imports | Hard to refactor | Named imports |
| Type + value import | Bundle bloat | import type |
API Design
Best practices for designing TypeScript interfaces, function signatures, and module APIs.
Interface Design Principles
Minimal Interfaces
// BAD: God interface with everything
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
lastLoginAt: Date;
preferences: UserPreferences;
roles: Role[];
orders: Order[];
notifications: Notification[];
}
// GOOD: Separate concerns
interface UserIdentity {
readonly id: string;
readonly email: string;
}
interface UserProfile {
readonly name: string;
readonly preferences: UserPreferences;
}
interface UserAuth {
readonly passwordHash: string;
readonly lastLoginAt: Date;
}
// Compose when needed
interface User extends UserIdentity, UserProfile {
readonly createdAt: Date;
readonly updatedAt: Date;
}Readonly by Default
// Mark properties as readonly unless mutation is required
interface Config {
readonly host: string;
readonly port: number;
readonly features: ReadonlyArray<string>;
readonly database: Readonly<DatabaseConfig>;
}
// Only omit readonly when mutation is intentional
interface MutableState {
count: number; // Intentionally mutable
items: string[]; // Intentionally mutable array
}Optional vs Required
// Be explicit about optionality
interface CreateUserInput {
email: string; // Required
name: string; // Required
phone?: string; // Optional
preferences?: Partial<UserPreferences>; // Optional partial
}
// Use undefined for "not set" vs null for "explicitly empty"
interface SearchResult {
data: Item[];
cursor: string | null; // null = no more pages
error: string | undefined; // undefined = no error
}Function Signatures
Parameter Design
// Prefer objects for 3+ parameters
// BAD
function createUser(
name: string,
email: string,
password: string,
role: string,
sendWelcomeEmail: boolean
): User;
// GOOD
interface CreateUserOptions {
readonly name: string;
readonly email: string;
readonly password: string;
readonly role?: UserRole;
readonly sendWelcomeEmail?: boolean;
}
function createUser(options: CreateUserOptions): User;
// Use defaults for optional parameters
function createUser({
name,
email,
password,
role = "user",
sendWelcomeEmail = true,
}: CreateUserOptions): User;Return Types
// Always specify return types for public APIs
function calculateTotal(items: ReadonlyArray<Item>): number;
// Use Result types for operations that can fail
function parseConfig(input: string): Result<Config, ParseError>;
// Return readonly types
function getUsers(): Promise<ReadonlyArray<User>>;
// Avoid returning undefined when null is more appropriate
function findById(id: string): User | null; // null = not foundFunction Overloads
// Use overloads for different input/output type combinations
function parse(input: string): ParsedData;
function parse(input: Buffer): ParsedData;
function parse(input: ReadableStream): Promise<ParsedData>;
function parse(
input: string | Buffer | ReadableStream
): ParsedData | Promise<ParsedData> {
// Implementation
}
// Prefer union types for simpler cases
function format(value: string | number | Date): string;Generic API Design
Meaningful Constraints
// Constrain generics to what's actually needed
function findById<T extends { id: string }>(
items: ReadonlyArray<T>,
id: string
): T | undefined {
return items.find(item => item.id === id);
}
// Use keyof for property access
function pluck<T, K extends keyof T>(
items: ReadonlyArray<T>,
key: K
): Array<T[K]> {
return items.map(item => item[key]);
}
// Default type parameters
function createStore<T = unknown>(): Store<T>;Generic Naming
// Use descriptive names for complex generics
function mapAsync<TInput, TOutput>(
items: ReadonlyArray<TInput>,
mapper: (item: TInput) => Promise<TOutput>
): Promise<TOutput[]>;
// Common conventions
// T - General type
// K - Key type
// V - Value type
// E - Error type
// R - Return type
// TItem - Element of collection
// TResult - Result of operationBuilder Pattern
Fluent Builder
interface QueryBuilder<T> {
where(condition: Partial<T>): QueryBuilder<T>;
orderBy(field: keyof T, direction?: "asc" | "desc"): QueryBuilder<T>;
limit(count: number): QueryBuilder<T>;
offset(count: number): QueryBuilder<T>;
execute(): Promise<T[]>;
}
function query<T>(table: string): QueryBuilder<T>;
// Usage
const users = await query<User>("users")
.where({ status: "active" })
.orderBy("createdAt", "desc")
.limit(10)
.execute();Options Builder
class RequestBuilder {
private options: RequestOptions = {};
url(url: string): this {
this.options.url = url;
return this;
}
method(method: HttpMethod): this {
this.options.method = method;
return this;
}
headers(headers: Record<string, string>): this {
this.options.headers = { ...this.options.headers, ...headers };
return this;
}
timeout(ms: number): this {
this.options.timeout = ms;
return this;
}
build(): Request {
return new Request(this.options);
}
}Versioning and Compatibility
Additive Changes (Non-Breaking)
// Adding optional properties is safe
interface UserV1 {
id: string;
name: string;
}
interface UserV2 extends UserV1 {
email?: string; // New optional property - safe
}
// Adding new methods with defaults is safe
interface ServiceV1 {
getUser(id: string): Promise<User>;
}
interface ServiceV2 extends ServiceV1 {
getUserByEmail?(email: string): Promise<User>; // Optional method - safe
}Breaking Changes
// These are breaking changes:
// 1. Removing properties
// 2. Making optional properties required
// 3. Changing property types
// 4. Changing return types
// 5. Adding required parameters
// Migration strategy: Create new type
interface UserV1 {
name: string;
}
interface UserV2 {
firstName: string; // Changed from name
lastName: string; // New required field
}
// Provide migration function
function migrateUserV1ToV2(user: UserV1): UserV2 {
const [firstName, ...rest] = user.name.split(" ");
return {
firstName,
lastName: rest.join(" ") || "Unknown",
};
}Deprecation Pattern
interface UserService {
/**
* @deprecated Use `findById` instead. Will be removed in v3.0.
*/
getUser(id: string): Promise<User>;
/**
* Find user by ID
* @since 2.0
*/
findById(id: string): Promise<User | null>;
}
// Runtime deprecation warning
function getUser(id: string): Promise<User> {
console.warn("getUser is deprecated. Use findById instead.");
return this.findById(id).then(user => {
if (!user) throw new Error("User not found");
return user;
});
}Error Design
Typed Errors
// Define error types for each failure mode
type UserError =
| { type: "not_found"; id: string }
| { type: "validation"; field: string; message: string }
| { type: "duplicate"; email: string };
// Use with Result type
function createUser(input: CreateUserInput): Result<User, UserError>;
// Or custom error classes
class UserNotFoundError extends Error {
readonly type = "not_found" as const;
constructor(readonly id: string) {
super(`User ${id} not found`);
}
}Error Codes
// Use error codes for programmatic handling
const UserErrorCodes = {
NOT_FOUND: "USER_NOT_FOUND",
INVALID_EMAIL: "USER_INVALID_EMAIL",
DUPLICATE_EMAIL: "USER_DUPLICATE_EMAIL",
UNAUTHORIZED: "USER_UNAUTHORIZED",
} as const;
type UserErrorCode = typeof UserErrorCodes[keyof typeof UserErrorCodes];
interface UserError {
code: UserErrorCode;
message: string;
details?: Record<string, unknown>;
}Documentation
JSDoc for Public APIs
/**
* Creates a new user account
*
* @param options - User creation options
* @returns The created user
* @throws {ValidationError} If email format is invalid
* @throws {DuplicateError} If email already exists
*
* @example
* ```typescript
* const user = await createUser({
* email: "john@example.com",
* name: "John Doe",
* });
* ```
*
* @since 1.0.0
* @see {@link updateUser} for updating existing users
*/
function createUser(options: CreateUserOptions): Promise<User>;
/**
* User creation options
*
* @property email - Must be a valid email format
* @property name - Display name (1-100 characters)
* @property role - User role (defaults to "user")
*/
interface CreateUserOptions {
readonly email: string;
readonly name: string;
readonly role?: UserRole;
}Best Practices
1. Design for Consumers: Think about how the API will be used 2. Minimize Surface Area: Export only what's needed 3. Use Readonly by Default: Mutability should be intentional 4. Prefer Objects for Options: Easier to extend and read 5. Return Consistent Types: Same operation, same return shape 6. Document Public APIs: JSDoc for functions and interfaces 7. Use Result Types: For operations that can fail 8. Version Carefully: Plan for backwards compatibility 9. Constrain Generics: Only require what's needed 10. Name Meaningfully: Types, functions, and parameters
Project Structure
Best practices for organizing TypeScript projects, directory structure, and configuration.
Directory Structures
Small Project
my-app/
├── src/
│ ├── index.ts # Entry point
│ ├── types.ts # Shared types
│ ├── utils.ts # Utility functions
│ └── config.ts # Configuration
├── tests/
│ └── index.test.ts
├── package.json
└── tsconfig.jsonMedium Project (Feature-Based)
my-app/
├── src/
│ ├── features/
│ │ ├── auth/
│ │ │ ├── index.ts
│ │ │ ├── auth.service.ts
│ │ │ ├── auth.types.ts
│ │ │ └── auth.test.ts
│ │ ├── users/
│ │ │ ├── index.ts
│ │ │ ├── user.service.ts
│ │ │ ├── user.repository.ts
│ │ │ ├── user.types.ts
│ │ │ └── user.test.ts
│ │ └── products/
│ │ └── ...
│ ├── shared/
│ │ ├── types/
│ │ │ └── common.ts
│ │ ├── utils/
│ │ │ ├── validation.ts
│ │ │ └── formatting.ts
│ │ └── constants.ts
│ ├── config/
│ │ ├── index.ts
│ │ └── env.ts
│ └── index.ts
├── tests/
│ └── integration/
├── package.json
└── tsconfig.jsonLarge Project (Layer-Based)
my-app/
├── src/
│ ├── domain/ # Business logic, entities
│ │ ├── user/
│ │ │ ├── user.entity.ts
│ │ │ ├── user.types.ts
│ │ │ └── user.errors.ts
│ │ └── order/
│ │ └── ...
│ ├── application/ # Use cases, services
│ │ ├── user/
│ │ │ ├── user.service.ts
│ │ │ ├── user.commands.ts
│ │ │ └── user.queries.ts
│ │ └── order/
│ │ └── ...
│ ├── infrastructure/ # External concerns
│ │ ├── database/
│ │ │ ├── connection.ts
│ │ │ └── repositories/
│ │ ├── api/
│ │ │ └── clients/
│ │ ├── cache/
│ │ │ └── redis.ts
│ │ └── messaging/
│ │ └── queue.ts
│ ├── presentation/ # UI, controllers, routes
│ │ ├── http/
│ │ │ ├── routes/
│ │ │ ├── middleware/
│ │ │ └── controllers/
│ │ └── graphql/
│ │ └── resolvers/
│ ├── shared/ # Cross-cutting concerns
│ │ ├── types/
│ │ ├── utils/
│ │ └── errors/
│ └── index.ts
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
└── tsconfig.jsonFile Naming Conventions
Consistent Naming
# Kebab-case for files (recommended)
user-service.ts
user-repository.ts
http-client.ts
# Or dot notation for type indication
user.service.ts
user.repository.ts
user.types.ts
user.test.ts
# Index files for public API
feature/
├── index.ts # Re-exports public API
├── internal.ts # Internal helpers (not exported)
└── feature.ts # ImplementationType File Organization
// user.types.ts - All types for user feature
// Entities
export interface User {
readonly id: string;
readonly email: string;
readonly name: string;
}
// DTOs
export interface CreateUserDto {
readonly email: string;
readonly name: string;
}
export interface UpdateUserDto {
readonly name?: string;
}
// Query/Filter types
export interface UserQuery {
readonly email?: string;
readonly name?: string;
readonly limit?: number;
readonly offset?: number;
}
// Result types
export type UserResult = Result<User, UserError>;TypeScript Configuration
Base tsconfig.json
{
"compilerOptions": {
// Language and Environment
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
// Strict Type Checking
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
// Module Handling
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
// Output
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
// Other
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}Recommended Strict Options
{
"compilerOptions": {
// All strict checks
"strict": true,
// Additional strict checks
"noUncheckedIndexedAccess": true, // arr[0] is T | undefined
"noImplicitOverride": true, // Require 'override' keyword
"noPropertyAccessFromIndexSignature": true, // Require bracket notation
"exactOptionalPropertyTypes": true, // undefined !== optional
// Error prevention
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}Project References (Monorepo)
// tsconfig.base.json (root)
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"strict": true
}
}
// packages/shared/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
// packages/app/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../shared" }
],
"include": ["src/**/*"]
}Path Aliases
Configuration
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/features/*": ["src/features/*"],
"@/shared/*": ["src/shared/*"],
"@/config": ["src/config/index.ts"]
}
}
}Usage
// Instead of relative imports
import { User } from "../../../shared/types/user";
// Use path aliases
import { User } from "@/shared/types/user";
import { UserService } from "@/features/users";
import { config } from "@/config";Test Organization
Co-located Tests
src/
├── features/
│ └── users/
│ ├── user.service.ts
│ ├── user.service.test.ts # Unit tests next to source
│ └── user.types.tsSeparate Test Directory
src/
├── features/
│ └── users/
│ └── user.service.ts
tests/
├── unit/
│ └── features/
│ └── users/
│ └── user.service.test.ts
├── integration/
│ └── users.test.ts
└── e2e/
└── api.test.tsTest Utilities
tests/
├── fixtures/ # Test data
│ ├── users.ts
│ └── products.ts
├── mocks/ # Mock implementations
│ ├── database.ts
│ └── api.ts
├── helpers/ # Test utilities
│ ├── setup.ts
│ └── factories.ts
└── ...Build Configuration
Package.json Scripts
{
"scripts": {
"build": "tsc --build",
"build:watch": "tsc --build --watch",
"clean": "rm -rf dist",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
}
}Build Outputs
dist/
├── index.js # Compiled JavaScript
├── index.js.map # Source maps
├── index.d.ts # Type declarations
└── index.d.ts.map # Declaration mapsEnvironment Configuration
Environment Files
.env # Default/development
.env.local # Local overrides (gitignored)
.env.production # Production settings
.env.test # Test environmentType-Safe Config
// config/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
DEBUG: z.coerce.boolean().default(false),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Invalid environment variables:");
console.error(result.error.format());
process.exit(1);
}
return result.data;
}
// config/index.ts
import { loadEnv } from "./env";
const env = loadEnv();
export const config = {
env: env.NODE_ENV,
port: env.PORT,
database: {
url: env.DATABASE_URL,
},
api: {
key: env.API_KEY,
},
debug: env.DEBUG,
} as const;Best Practices
1. Group by Feature: Keep related code together 2. Limit Nesting Depth: Max 3-4 levels deep 3. One Concept Per File: Small, focused modules 4. Use Index Files Sparingly: Only for public APIs 5. Co-locate Tests: Near the code they test 6. Type-Safe Configuration: Validate at startup 7. Use Path Aliases: Avoid long relative imports 8. Separate Source and Output: src/ and dist/ 9. Enable Strict Mode: Maximum type safety 10. Document Structure: README in each major directory
Async Patterns
Best practices for async/await, Promise handling, cancellation, and concurrent operations in TypeScript.
Async/Await Basics
Proper Async Function Signatures
// Always specify return type for public APIs
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/users/${id}`);
return response.json();
}
// Use Result types for error handling
async function fetchUserSafe(id: string): Promise<Result<User, FetchError>> {
try {
const response = await fetch(`/users/${id}`);
if (!response.ok) {
return err({ type: "http", status: response.status });
}
return ok(await response.json());
} catch (error) {
return err({ type: "network", message: String(error) });
}
}Avoid Unnecessary Async
// WRONG: Unnecessary async wrapper
async function getConfig(): Promise<Config> {
return config; // No await needed
}
// CORRECT: Just return the promise or value
function getConfig(): Config {
return config;
}
// WRONG: Wrapping a promise in async
async function fetchData(): Promise<Data> {
return await fetch("/data").then(r => r.json());
}
// CORRECT: Return the promise directly
function fetchData(): Promise<Data> {
return fetch("/data").then(r => r.json());
}
// CORRECT: Use async when you need multiple awaits
async function processData(): Promise<ProcessedData> {
const raw = await fetch("/data");
const json = await raw.json();
return transform(json);
}Sequential vs Parallel Execution
Sequential Execution
// Each request waits for the previous one
async function fetchSequential(ids: string[]): Promise<User[]> {
const users: User[] = [];
for (const id of ids) {
const user = await fetchUser(id);
users.push(user);
}
return users;
}
// Use when:
// - Order matters
// - Rate limiting required
// - Each request depends on previous resultParallel Execution
// All requests run simultaneously
async function fetchParallel(ids: string[]): Promise<User[]> {
const promises = ids.map(id => fetchUser(id));
return Promise.all(promises);
}
// Use when:
// - Requests are independent
// - Faster total execution time needed
// - No rate limiting concernsControlled Concurrency
// Limit concurrent requests
async function fetchWithConcurrency<T>(
items: string[],
fetcher: (item: string) => Promise<T>,
concurrency: number
): Promise<T[]> {
const results: T[] = [];
const executing: Promise<void>[] = [];
for (const item of items) {
const promise = fetcher(item).then(result => {
results.push(result);
});
executing.push(promise);
if (executing.length >= concurrency) {
await Promise.race(executing);
// Remove completed promises
executing.splice(
executing.findIndex(p => p === promise),
1
);
}
}
await Promise.all(executing);
return results;
}
// Usage: Max 3 concurrent requests
const users = await fetchWithConcurrency(ids, fetchUser, 3);Promise Utilities
Promise.all with Error Handling
// Fail-fast: First error rejects all
async function fetchAll<T>(promises: Promise<T>[]): Promise<T[]> {
return Promise.all(promises);
}
// Collect all results (success and failure)
async function fetchAllSettled<T>(
promises: Promise<T>[]
): Promise<Array<{ status: "fulfilled"; value: T } | { status: "rejected"; reason: unknown }>> {
return Promise.allSettled(promises);
}
// Separate successes and failures
async function partitionResults<T>(
promises: Promise<T>[]
): Promise<{ successes: T[]; failures: unknown[] }> {
const results = await Promise.allSettled(promises);
const successes: T[] = [];
const failures: unknown[] = [];
for (const result of results) {
if (result.status === "fulfilled") {
successes.push(result.value);
} else {
failures.push(result.reason);
}
}
return { successes, failures };
}Promise.race Patterns
// Timeout wrapper
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message = "Operation timed out"
): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeout]);
}
// Usage
const result = await withTimeout(fetchUser("123"), 5000);
// First successful result
async function fetchFirst<T>(promises: Promise<T>[]): Promise<T> {
return Promise.race(promises);
}Cancellation with AbortController
Basic Cancellation
async function fetchWithAbort(
url: string,
signal?: AbortSignal
): Promise<Response> {
return fetch(url, { signal });
}
// Usage
const controller = new AbortController();
// Start fetch
const fetchPromise = fetchWithAbort("/data", controller.signal);
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
const response = await fetchPromise;
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
console.log("Request was cancelled");
} else {
throw error;
}
}Cancellation in Long Operations
async function processLargeDataset(
items: Item[],
signal?: AbortSignal
): Promise<ProcessedItem[]> {
const results: ProcessedItem[] = [];
for (const item of items) {
// Check cancellation before each iteration
if (signal?.aborted) {
throw new DOMException("Operation cancelled", "AbortError");
}
const processed = await processItem(item);
results.push(processed);
}
return results;
}
// Helper for cancellation check
function checkAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new DOMException("Operation cancelled", "AbortError");
}
}Linked Abort Signals
// Combine multiple abort signals
function linkAbortSignals(...signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort();
break;
}
signal.addEventListener("abort", () => controller.abort(), { once: true });
}
return controller.signal;
}
// Usage: Cancel on either timeout or user action
const timeoutController = new AbortController();
setTimeout(() => timeoutController.abort(), 30000);
const userController = new AbortController();
cancelButton.onclick = () => userController.abort();
const combinedSignal = linkAbortSignals(
timeoutController.signal,
userController.signal
);
await fetchWithAbort("/data", combinedSignal);Retry Patterns
Simple Retry
async function retry<T>(
fn: () => Promise<T>,
maxAttempts: number
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts) break;
}
}
throw lastError;
}
// Usage
const result = await retry(() => fetchData(), 3);Retry with Exponential Backoff
interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
signal?: AbortSignal;
}
async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: RetryOptions
): Promise<T> {
const {
maxAttempts,
initialDelayMs,
maxDelayMs,
backoffMultiplier,
signal,
} = options;
let delay = initialDelayMs;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts) break;
if (signal?.aborted) break;
// Wait before retry
await sleep(delay);
// Increase delay for next attempt
delay = Math.min(delay * backoffMultiplier, maxDelayMs);
}
}
throw lastError;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
const result = await retryWithBackoff(fetchData, {
maxAttempts: 5,
initialDelayMs: 100,
maxDelayMs: 10000,
backoffMultiplier: 2,
});Debouncing and Throttling
Async Debounce
function debounceAsync<T extends unknown[], R>(
fn: (...args: T) => Promise<R>,
delayMs: number
): (...args: T) => Promise<R> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let pendingPromise: Promise<R> | null = null;
let resolve: ((value: R) => void) | null = null;
let reject: ((error: unknown) => void) | null = null;
return (...args: T): Promise<R> => {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (!pendingPromise) {
pendingPromise = new Promise<R>((res, rej) => {
resolve = res;
reject = rej;
});
}
timeoutId = setTimeout(async () => {
try {
const result = await fn(...args);
resolve?.(result);
} catch (error) {
reject?.(error);
} finally {
pendingPromise = null;
resolve = null;
reject = null;
}
}, delayMs);
return pendingPromise;
};
}
// Usage
const debouncedSearch = debounceAsync(searchApi, 300);Queue Patterns
Simple Async Queue
class AsyncQueue<T> {
private queue: Array<() => Promise<T>> = [];
private processing = false;
private results: T[] = [];
add(task: () => Promise<T>): void {
this.queue.push(task);
this.process();
}
private async process(): Promise<void> {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
const task = this.queue.shift()!;
const result = await task();
this.results.push(result);
}
this.processing = false;
}
async drain(): Promise<T[]> {
while (this.processing || this.queue.length > 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
return this.results;
}
}Best Practices
1. Always Handle Errors: Use try-catch or Result types 2. Avoid Floating Promises: Always await or handle promise 3. Use Parallel When Possible: Promise.all for independent operations 4. Implement Timeouts: Prevent hanging operations 5. Support Cancellation: Use AbortController for long operations 6. Add Retries for Network: With exponential backoff 7. Limit Concurrency: Prevent overwhelming servers 8. Type Return Values: Explicit Promise<T> for public APIs
Error Handling Patterns
Type-safe error handling in TypeScript using Result types, typed errors, and discriminated unions.
The Result Type Pattern
Basic Result Type
/**
* Result type for operations that can fail
* Success: { success: true, value: T }
* Failure: { success: false, error: E }
*/
type Result<T, E = Error> =
| { readonly success: true; readonly value: T }
| { readonly success: false; readonly error: E };
// Factory functions
function ok<T>(value: T): Result<T, never> {
return { success: true, value };
}
function err<E>(error: E): Result<never, E> {
return { success: false, error };
}Using Result Types
function parseJson<T>(input: string): Result<T, SyntaxError> {
try {
return ok(JSON.parse(input) as T);
} catch (error) {
return err(error instanceof SyntaxError ? error : new SyntaxError(String(error)));
}
}
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err("Division by zero");
}
return ok(a / b);
}
// Usage
const result = divide(10, 2);
if (result.success) {
console.log(result.value); // 5, TypeScript knows this is number
} else {
console.error(result.error); // TypeScript knows this is string
}Chaining Results
// Map: Transform success value
function mapResult<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.success) {
return ok(fn(result.value));
}
return result;
}
// FlatMap: Chain operations that return Results
function flatMapResult<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.success) {
return fn(result.value);
}
return result;
}
// Example: Chained operations
function processData(input: string): Result<number, string> {
const parsed = parseJson<{ value: number }>(input);
return flatMapResult(
mapResult(parsed, (data) => data.value),
(value) => divide(value, 2)
);
}Typed Error Classes
Custom Error Classes
// Base application error
abstract class AppError extends Error {
abstract readonly code: string;
abstract readonly statusCode: number;
constructor(message: string, readonly cause?: unknown) {
super(message);
this.name = this.constructor.name;
// Maintain proper stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
toJSON(): Record<string, unknown> {
return {
name: this.name,
code: this.code,
message: this.message,
statusCode: this.statusCode,
};
}
}
// Specific error types
class ValidationError extends AppError {
readonly code = "VALIDATION_ERROR";
readonly statusCode = 400;
constructor(
message: string,
readonly field: string,
readonly constraints: string[]
) {
super(message);
}
}
class NotFoundError extends AppError {
readonly code = "NOT_FOUND";
readonly statusCode = 404;
constructor(readonly resource: string, readonly id: string) {
super(`${resource} with id ${id} not found`);
}
}
class UnauthorizedError extends AppError {
readonly code = "UNAUTHORIZED";
readonly statusCode = 401;
constructor(message = "Unauthorized access") {
super(message);
}
}Error Handling with Custom Errors
type UserResult = Result<User, ValidationError | NotFoundError>;
async function getUser(id: string): Promise<UserResult> {
if (!isValidId(id)) {
return err(new ValidationError("Invalid ID format", "id", ["Must be UUID"]));
}
const user = await db.findUser(id);
if (!user) {
return err(new NotFoundError("User", id));
}
return ok(user);
}
// Handle specific error types
const result = await getUser("invalid");
if (!result.success) {
const error = result.error;
if (error instanceof ValidationError) {
console.log(`Field ${error.field}: ${error.constraints.join(", ")}`);
} else if (error instanceof NotFoundError) {
console.log(`${error.resource} ${error.id} not found`);
}
}Error Union Types
Discriminated Error Unions
type ApiError =
| { type: "network"; message: string; retryable: boolean }
| { type: "validation"; field: string; message: string }
| { type: "auth"; code: "expired" | "invalid" | "missing" }
| { type: "server"; statusCode: number; message: string };
function handleApiError(error: ApiError): string {
switch (error.type) {
case "network":
return error.retryable ? "Please try again" : "Network unavailable";
case "validation":
return `Invalid ${error.field}: ${error.message}`;
case "auth":
return error.code === "expired" ? "Session expired" : "Please log in";
case "server":
return `Server error (${error.statusCode})`;
}
}Multiple Error Types
type ParseError = { type: "parse"; line: number; message: string };
type ValidateError = { type: "validate"; path: string; expected: string };
type TransformError = { type: "transform"; step: string; cause: unknown };
type ProcessError = ParseError | ValidateError | TransformError;
function process(input: string): Result<Output, ProcessError> {
const parsed = parse(input);
if (!parsed.success) {
return err({ type: "parse", line: parsed.line, message: parsed.message });
}
const validated = validate(parsed.value);
if (!validated.success) {
return err({ type: "validate", path: validated.path, expected: validated.expected });
}
try {
return ok(transform(validated.value));
} catch (cause) {
return err({ type: "transform", step: "final", cause });
}
}Try-Catch Patterns
Typed Try-Catch Wrapper
function tryCatch<T>(fn: () => T): Result<T, Error> {
try {
return ok(fn());
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
}
async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
}
// Usage
const result = tryCatch(() => JSON.parse(input));
const asyncResult = await tryCatchAsync(() => fetch(url));Error Context
class ContextualError extends Error {
constructor(
message: string,
readonly context: Record<string, unknown>,
readonly cause?: Error
) {
super(message);
this.name = "ContextualError";
}
}
function wrapError(
error: unknown,
message: string,
context: Record<string, unknown> = {}
): ContextualError {
const cause = error instanceof Error ? error : new Error(String(error));
return new ContextualError(message, context, cause);
}
// Usage
async function fetchUserData(userId: string): Promise<Result<UserData, ContextualError>> {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return ok(data);
} catch (error) {
return err(wrapError(error, "Failed to fetch user data", { userId }));
}
}Async Error Handling
Promise-Based Results
type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
async function fetchData<T>(url: string): AsyncResult<T> {
try {
const response = await fetch(url);
if (!response.ok) {
return err(new Error(`HTTP ${response.status}: ${response.statusText}`));
}
const data = await response.json();
return ok(data as T);
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
}
// Chain async results
async function getUserPosts(userId: string): AsyncResult<Post[]> {
const userResult = await fetchData<User>(`/users/${userId}`);
if (!userResult.success) {
return userResult;
}
return fetchData<Post[]>(`/users/${userId}/posts`);
}Collecting Results
async function collectResults<T, E>(
results: Array<Promise<Result<T, E>>>
): Promise<Result<T[], E>> {
const settled = await Promise.all(results);
const errors: E[] = [];
const values: T[] = [];
for (const result of settled) {
if (result.success) {
values.push(result.value);
} else {
errors.push(result.error);
}
}
if (errors.length > 0) {
return err(errors[0]); // Return first error
}
return ok(values);
}
// Collect all errors
async function collectAllResults<T, E>(
results: Array<Promise<Result<T, E>>>
): Promise<Result<T[], E[]>> {
const settled = await Promise.all(results);
const errors: E[] = [];
const values: T[] = [];
for (const result of settled) {
if (result.success) {
values.push(result.value);
} else {
errors.push(result.error);
}
}
if (errors.length > 0) {
return { success: false, error: errors };
}
return ok(values);
}Best Practices
1. Use Result Types for Expected Failures: Network errors, validation, not found 2. Throw for Programmer Errors: Null pointer, type errors, invariant violations 3. Include Error Context: Add relevant data for debugging 4. Use Discriminated Unions: Enable exhaustive error handling 5. Don't Swallow Errors: Always handle or propagate 6. Log at Boundaries: Log errors at API/service boundaries 7. Prefer Specific Error Types: Over generic Error class 8. Document Error Cases: In function signatures and JSDoc
Functional Patterns
Functional programming patterns in TypeScript: immutability, pure functions, composition, and higher-order functions.
Immutability
Readonly Types
// Immutable object
interface User {
readonly id: string;
readonly name: string;
readonly email: string;
}
// Immutable array
type UserList = ReadonlyArray<User>;
// Deep readonly
type DeepReadonly<T> = T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;
// Const assertion for literals
const CONFIG = {
host: "localhost",
port: 3000,
features: ["auth", "logging"],
} as const;
// typeof CONFIG = { readonly host: "localhost"; readonly port: 3000; readonly features: readonly ["auth", "logging"] }Immutable Updates
// Object updates with spread
function updateUser(user: User, name: string): User {
return { ...user, name };
}
// Nested updates
interface State {
readonly user: User;
readonly settings: {
readonly theme: string;
readonly notifications: boolean;
};
}
function updateTheme(state: State, theme: string): State {
return {
...state,
settings: {
...state.settings,
theme,
},
};
}
// Array updates
function addItem<T>(items: ReadonlyArray<T>, item: T): ReadonlyArray<T> {
return [...items, item];
}
function removeItem<T>(items: ReadonlyArray<T>, index: number): ReadonlyArray<T> {
return [...items.slice(0, index), ...items.slice(index + 1)];
}
function updateItem<T>(
items: ReadonlyArray<T>,
index: number,
updater: (item: T) => T
): ReadonlyArray<T> {
return items.map((item, i) => (i === index ? updater(item) : item));
}Pure Functions
Characteristics of Pure Functions
// Pure: Same input always produces same output, no side effects
function add(a: number, b: number): number {
return a + b;
}
function formatUser(user: User): string {
return `${user.name} <${user.email}>`;
}
// Impure: Uses external state
let counter = 0;
function increment(): number {
return ++counter; // Side effect: modifies external state
}
// Impure: Non-deterministic
function getTimestamp(): number {
return Date.now(); // Different result each call
}
// Impure: Side effects
function logUser(user: User): User {
console.log(user); // Side effect: I/O
return user;
}Converting Impure to Pure
// Impure: Random selection
function pickRandomItem<T>(items: T[]): T {
return items[Math.floor(Math.random() * items.length)];
}
// Pure: Inject randomness
function pickItem<T>(items: ReadonlyArray<T>, randomValue: number): T {
const index = Math.floor(randomValue * items.length);
return items[index];
}
// Impure: Uses current time
function isExpired(expiresAt: Date): boolean {
return expiresAt < new Date();
}
// Pure: Inject current time
function isExpiredAt(expiresAt: Date, now: Date): boolean {
return expiresAt < now;
}Function Composition
Basic Composition
// Compose two functions
function compose<A, B, C>(
f: (b: B) => C,
g: (a: A) => B
): (a: A) => C {
return (a: A) => f(g(a));
}
// Compose multiple functions (right to left)
function composeAll<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
return (arg: T) => fns.reduceRight((acc, fn) => fn(acc), arg);
}
// Pipe (left to right, more readable)
function pipe<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
return (arg: T) => fns.reduce((acc, fn) => fn(acc), arg);
}
// Usage
const processString = pipe(
(s: string) => s.trim(),
(s: string) => s.toLowerCase(),
(s: string) => s.replace(/\s+/g, "-")
);
processString(" Hello World "); // "hello-world"Typed Pipe
// Type-safe pipe for different types
function pipe2<A, B, C>(
ab: (a: A) => B,
bc: (b: B) => C
): (a: A) => C {
return (a: A) => bc(ab(a));
}
function pipe3<A, B, C, D>(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D
): (a: A) => D {
return (a: A) => cd(bc(ab(a)));
}
// Usage with different types
const parseAndDouble = pipe2(
(s: string) => parseInt(s, 10),
(n: number) => n * 2
);
parseAndDouble("21"); // 42Higher-Order Functions
Functions That Return Functions
// Factory function
function createMultiplier(factor: number): (value: number) => number {
return (value: number) => value * factor;
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
double(5); // 10
triple(5); // 15
// Predicate factory
function createMatcher<T>(
key: keyof T,
value: T[keyof T]
): (item: T) => boolean {
return (item: T) => item[key] === value;
}
const isActive = createMatcher<User>("status", "active");
const activeUsers = users.filter(isActive);Functions That Accept Functions
// Map with index
function mapWithIndex<T, U>(
items: ReadonlyArray<T>,
fn: (item: T, index: number) => U
): U[] {
return items.map(fn);
}
// Filter and map in one pass
function filterMap<T, U>(
items: ReadonlyArray<T>,
predicate: (item: T) => boolean,
mapper: (item: T) => U
): U[] {
const result: U[] = [];
for (const item of items) {
if (predicate(item)) {
result.push(mapper(item));
}
}
return result;
}
// Group by key
function groupBy<T, K extends string | number>(
items: ReadonlyArray<T>,
getKey: (item: T) => K
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of items) {
const key = getKey(item);
if (!result[key]) {
result[key] = [];
}
result[key].push(item);
}
return result;
}Currying and Partial Application
Currying
// Curried function
function curriedAdd(a: number): (b: number) => (c: number) => number {
return (b: number) => (c: number) => a + b + c;
}
curriedAdd(1)(2)(3); // 6
// Generic curry helper (for 2 args)
function curry2<A, B, R>(fn: (a: A, b: B) => R): (a: A) => (b: B) => R {
return (a: A) => (b: B) => fn(a, b);
}
// Usage
const curriedConcat = curry2((a: string, b: string) => a + b);
const greet = curriedConcat("Hello, ");
greet("World"); // "Hello, World"Partial Application
// Partial application helper
function partial<T, U extends unknown[], R>(
fn: (arg: T, ...rest: U) => R,
arg: T
): (...rest: U) => R {
return (...rest: U) => fn(arg, ...rest);
}
// Usage
function greet(greeting: string, name: string): string {
return `${greeting}, ${name}!`;
}
const sayHello = partial(greet, "Hello");
sayHello("World"); // "Hello, World!"
// Partial with multiple args
function partialRight<T extends unknown[], U, R>(
fn: (...args: [...T, U]) => R,
arg: U
): (...args: T) => R {
return (...args: T) => fn(...args, arg);
}Option/Maybe Pattern
Option Type
type Some<T> = { readonly _tag: "some"; readonly value: T };
type None = { readonly _tag: "none" };
type Option<T> = Some<T> | None;
// Constructors
function some<T>(value: T): Option<T> {
return { _tag: "some", value };
}
function none<T = never>(): Option<T> {
return { _tag: "none" };
}
// Type guards
function isSome<T>(option: Option<T>): option is Some<T> {
return option._tag === "some";
}
function isNone<T>(option: Option<T>): option is None {
return option._tag === "none";
}Option Operations
// Map
function mapOption<T, U>(
option: Option<T>,
fn: (value: T) => U
): Option<U> {
return isSome(option) ? some(fn(option.value)) : none();
}
// FlatMap
function flatMapOption<T, U>(
option: Option<T>,
fn: (value: T) => Option<U>
): Option<U> {
return isSome(option) ? fn(option.value) : none();
}
// Get with default
function getOrElse<T>(option: Option<T>, defaultValue: T): T {
return isSome(option) ? option.value : defaultValue;
}
// Usage
function findUser(id: string): Option<User> {
const user = users.get(id);
return user ? some(user) : none();
}
const userName = pipe(
findUser("123"),
opt => mapOption(opt, user => user.name),
opt => getOrElse(opt, "Unknown")
);Lens Pattern
Simple Lens
interface Lens<S, A> {
get: (s: S) => A;
set: (a: A) => (s: S) => S;
}
// Create lens for a property
function lens<S, K extends keyof S>(key: K): Lens<S, S[K]> {
return {
get: (s: S) => s[key],
set: (a: S[K]) => (s: S) => ({ ...s, [key]: a }),
};
}
// Modify through lens
function over<S, A>(
l: Lens<S, A>,
fn: (a: A) => A
): (s: S) => S {
return (s: S) => l.set(fn(l.get(s)))(s);
}
// Compose lenses
function composeLens<S, A, B>(
outer: Lens<S, A>,
inner: Lens<A, B>
): Lens<S, B> {
return {
get: (s: S) => inner.get(outer.get(s)),
set: (b: B) => (s: S) => outer.set(inner.set(b)(outer.get(s)))(s),
};
}
// Usage
interface Address {
street: string;
city: string;
}
interface Person {
name: string;
address: Address;
}
const addressLens = lens<Person, "address">("address");
const cityLens = lens<Address, "city">("city");
const personCityLens = composeLens(addressLens, cityLens);
const person: Person = { name: "John", address: { street: "Main", city: "NYC" } };
const updatedPerson = personCityLens.set("LA")(person);Best Practices
1. Prefer Immutability: Use readonly and spread operators 2. Keep Functions Pure: Same input, same output, no side effects 3. Use Small, Focused Functions: Each function does one thing 4. Compose Functions: Build complex logic from simple pieces 5. Use Higher-Order Functions: For reusable patterns 6. Type Everything: Explicit types for function signatures 7. Use Option for Missing Values: Instead of null/undefined 8. Document Side Effects: When they're unavoidable
Module Patterns
Best practices for TypeScript module organization, exports, dependency injection, and circular dependency prevention.
Export Patterns
Named Exports (Preferred)
// user.ts - Named exports
export interface User {
readonly id: string;
readonly name: string;
}
export function createUser(name: string): User {
return { id: generateId(), name };
}
export function validateUser(user: unknown): user is User {
return (
typeof user === "object" &&
user !== null &&
typeof (user as User).id === "string" &&
typeof (user as User).name === "string"
);
}
// Importing
import { User, createUser, validateUser } from "./user.ts";Re-exports
// domain/user/index.ts - Re-export module contents
export { User, createUser } from "./user.ts";
export { UserService } from "./user-service.ts";
export { UserRepository } from "./user-repository.ts";
// types.ts - Re-export only types
export type { User } from "./user.ts";
export type { UserService } from "./user-service.ts";Barrel Files (Use Carefully)
// Avoid wildcard re-exports (causes tree-shaking issues)
// BAD
export * from "./user.ts";
export * from "./product.ts";
export * from "./order.ts";
// BETTER: Explicit re-exports
export { User, createUser } from "./user.ts";
export { Product, createProduct } from "./product.ts";
export { Order, createOrder } from "./order.ts";
// BEST: Import from specific modules when possible
// Instead of: import { User, Product } from "./domain";
// Use: import { User } from "./domain/user";Module Organization
Feature-Based Structure
src/
├── features/
│ ├── auth/
│ │ ├── index.ts # Public API
│ │ ├── auth.service.ts
│ │ ├── auth.types.ts
│ │ └── auth.utils.ts
│ ├── users/
│ │ ├── index.ts
│ │ ├── user.service.ts
│ │ ├── user.types.ts
│ │ └── user.repository.ts
│ └── products/
│ ├── index.ts
│ ├── product.service.ts
│ └── product.types.ts
├── shared/
│ ├── types/
│ ├── utils/
│ └── constants/
└── index.ts # App entry pointLayer-Based Structure
src/
├── domain/ # Business logic, entities
│ ├── user.ts
│ └── product.ts
├── application/ # Use cases, services
│ ├── user.service.ts
│ └── product.service.ts
├── infrastructure/ # External concerns
│ ├── database/
│ ├── api/
│ └── cache/
└── presentation/ # UI, controllers
├── routes/
└── controllers/Dependency Injection
Constructor Injection
// Interfaces for dependencies
interface Logger {
log(message: string): void;
error(message: string): void;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// Service with injected dependencies
class UserService {
constructor(
private readonly repository: UserRepository,
private readonly logger: Logger
) {}
async getUser(id: string): Promise<User | null> {
this.logger.log(`Fetching user: ${id}`);
return this.repository.findById(id);
}
}
// Composition root (where dependencies are wired)
const logger = new ConsoleLogger();
const repository = new PostgresUserRepository(db);
const userService = new UserService(repository, logger);Factory Functions
// Factory with dependencies
interface UserServiceDeps {
readonly repository: UserRepository;
readonly logger: Logger;
}
function createUserService(deps: UserServiceDeps) {
return {
async getUser(id: string): Promise<User | null> {
deps.logger.log(`Fetching user: ${id}`);
return deps.repository.findById(id);
},
async saveUser(user: User): Promise<void> {
deps.logger.log(`Saving user: ${user.id}`);
await deps.repository.save(user);
},
};
}
// Usage
const userService = createUserService({
repository: new PostgresUserRepository(db),
logger: new ConsoleLogger(),
});Context Pattern
// Application context with all dependencies
interface AppContext {
readonly config: Config;
readonly logger: Logger;
readonly db: Database;
readonly cache: Cache;
}
// Services receive context
function createServices(ctx: AppContext) {
return {
users: createUserService({
repository: new UserRepository(ctx.db),
logger: ctx.logger,
}),
products: createProductService({
repository: new ProductRepository(ctx.db),
cache: ctx.cache,
logger: ctx.logger,
}),
};
}
// Bootstrap
const context: AppContext = {
config: loadConfig(),
logger: new Logger(),
db: new Database(config.db),
cache: new RedisCache(config.redis),
};
const services = createServices(context);Avoiding Circular Dependencies
Problem Example
// user.ts
import { Order } from "./order.ts"; // Circular!
export interface User {
id: string;
orders: Order[];
}
// order.ts
import { User } from "./user.ts"; // Circular!
export interface Order {
id: string;
user: User;
}Solution 1: Extract Shared Types
// types.ts - Shared types
export interface UserId {
readonly userId: string;
}
export interface OrderId {
readonly orderId: string;
}
// user.ts
import { UserId, OrderId } from "./types.ts";
export interface User extends UserId {
name: string;
orderIds: OrderId["orderId"][];
}
// order.ts
import { UserId, OrderId } from "./types.ts";
export interface Order extends OrderId {
userId: UserId["userId"];
total: number;
}Solution 2: Dependency Inversion
// Define interfaces in a shared location
// interfaces/user-loader.ts
export interface UserLoader {
loadUser(id: string): Promise<User>;
}
// order.service.ts - Depends on interface
export class OrderService {
constructor(private readonly userLoader: UserLoader) {}
async getOrderWithUser(orderId: string): Promise<OrderWithUser> {
const order = await this.orderRepo.findById(orderId);
const user = await this.userLoader.loadUser(order.userId);
return { ...order, user };
}
}
// user.service.ts - Implements interface
export class UserService implements UserLoader {
async loadUser(id: string): Promise<User> {
return this.userRepo.findById(id);
}
}Solution 3: Lazy Imports
// Only import when needed (breaks static dependency)
export class OrderService {
async getOrderWithUser(orderId: string): Promise<OrderWithUser> {
const order = await this.orderRepo.findById(orderId);
// Dynamic import - only when needed
const { UserService } = await import("./user.service.ts");
const userService = new UserService();
const user = await userService.getUser(order.userId);
return { ...order, user };
}
}Module Initialization
Lazy Initialization
// Lazy singleton
let instance: DatabaseConnection | null = null;
export async function getDatabase(): Promise<DatabaseConnection> {
if (!instance) {
instance = await createDatabaseConnection(config);
}
return instance;
}
// Usage
const db = await getDatabase();Module-Level Async Initialization
// config.ts
let config: Config | null = null;
export async function initConfig(): Promise<void> {
config = await loadConfigFromFile();
}
export function getConfig(): Config {
if (!config) {
throw new Error("Config not initialized. Call initConfig() first.");
}
return config;
}
// main.ts
await initConfig();
const config = getConfig();Initialization Order
// init.ts - Central initialization
import { initDatabase } from "./database.ts";
import { initCache } from "./cache.ts";
import { initServices } from "./services.ts";
export async function init(): Promise<AppContext> {
// Initialize in dependency order
const db = await initDatabase();
const cache = await initCache();
const services = await initServices({ db, cache });
return { db, cache, services };
}
// main.ts
const app = await init();Type-Only Imports
Separate Type and Value Imports
// Import types separately (removed at compile time)
import type { User, UserRole } from "./user.ts";
import { createUser, validateUser } from "./user.ts";
// Or use inline type imports
import { createUser, type User } from "./user.ts";Type-Only Re-exports
// Export types only (for .d.ts generation)
export type { User, UserRole } from "./user.ts";
// Re-export values and types
export { createUser } from "./user.ts";
export type { User } from "./user.ts";Best Practices
1. Prefer Named Exports: Better tree-shaking and refactoring 2. Avoid Barrel File Wildcards: Use explicit re-exports 3. One Concept Per File: Keep modules focused 4. Use Type-Only Imports: For interfaces and types 5. Inject Dependencies: Don't import singletons directly 6. Define Interfaces for Dependencies: Enable testing and flexibility 7. Watch for Circular Dependencies: Use dependency inversion 8. Initialize Explicitly: Don't rely on import side effects 9. Feature-Based Organization: Group related code together 10. Keep Public API Small: Export only what's needed
Advanced Types
Deep dive into TypeScript's advanced type features: generics, conditional types, mapped types, and template literal types.
Generics
Basic Generic Functions
// Generic function with constraint
function first<T>(arr: ReadonlyArray<T>): T | undefined {
return arr[0];
}
// Generic with default
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
// Multiple type parameters
function zip<T, U>(a: ReadonlyArray<T>, b: ReadonlyArray<U>): Array<[T, U]> {
return a.map((item, i) => [item, b[i]]);
}Generic Constraints
// Constrain to object with specific property
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Constrain to objects with certain shape
interface HasLength {
readonly length: number;
}
function logLength<T extends HasLength>(value: T): void {
console.log(value.length);
}
// Works with string, array, or any object with length
logLength("hello"); // 5
logLength([1, 2, 3]); // 3
logLength({ length: 10 }); // 10Generic Classes
class Container<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
get(index: number): T | undefined {
return this.items[index];
}
getAll(): ReadonlyArray<T> {
return this.items;
}
}
// With constraint
class Repository<T extends { id: string }> {
private store = new Map<string, T>();
save(item: T): void {
this.store.set(item.id, item);
}
findById(id: string): T | undefined {
return this.store.get(id);
}
}Conditional Types
Basic Conditional Types
// T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<123>; // false
// Extract array element type
type ElementType<T> = T extends ReadonlyArray<infer E> ? E : never;
type C = ElementType<string[]>; // string
type D = ElementType<number>; // neverDistributive Conditional Types
// Conditional types distribute over unions
type ToArray<T> = T extends unknown ? T[] : never;
type E = ToArray<string | number>; // string[] | number[]
// Prevent distribution with tuple
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type F = ToArrayNonDist<string | number>; // (string | number)[]The infer Keyword
// Extract return type
type ReturnOf<T> = T extends (...args: unknown[]) => infer R ? R : never;
type G = ReturnOf<() => string>; // string
type H = ReturnOf<(x: number) => boolean>; // boolean
// Extract promise value
type Awaited<T> = T extends Promise<infer V> ? V : T;
type I = Awaited<Promise<string>>; // string
type J = Awaited<string>; // string
// Extract function parameters
type Parameters<T> = T extends (...args: infer P) => unknown ? P : never;
type K = Parameters<(a: string, b: number) => void>; // [string, number]Practical Conditional Types
// Non-nullable
type NonNullable<T> = T extends null | undefined ? never : T;
// Exclude types from union
type Exclude<T, U> = T extends U ? never : T;
type L = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
// Extract types from union
type Extract<T, U> = T extends U ? T : never;
type M = Extract<string | number | boolean, number>; // numberMapped Types
Basic Mapped Types
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Make all properties required
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Remove readonly
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};Key Remapping
// Rename keys with template literals
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
// Filter keys
type OnlyStrings<T> = {
[P in keyof T as T[P] extends string ? P : never]: T[P];
};
type N = OnlyStrings<{ name: string; age: number; email: string }>;
// { name: string; email: string }Mapped Type Modifiers
// Pick specific keys
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Omit specific keys
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// Record type
type Record<K extends keyof unknown, T> = {
[P in K]: T;
};
type O = Record<"a" | "b", number>; // { a: number; b: number }Template Literal Types
Basic Template Literals
// String manipulation
type Greeting = `Hello, ${string}`;
const g1: Greeting = "Hello, World"; // OK
const g2: Greeting = "Hi, World"; // Error
// Union expansion
type Color = "red" | "blue";
type Size = "small" | "large";
type ColorSize = `${Color}-${Size}`;
// "red-small" | "red-large" | "blue-small" | "blue-large"Built-in String Manipulation Types
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Event Handler Pattern
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
type EventHandlers = {
[E in EventName as `on${Capitalize<E>}`]: (event: Event) => void;
};
// { onClick: ..., onFocus: ..., onBlur: ... }Path Types
// Nested property paths
type PropPath<T, Prefix extends string = ""> = {
[K in keyof T]: T[K] extends object
? PropPath<T[K], `${Prefix}${K & string}.`>
: `${Prefix}${K & string}`;
}[keyof T];
interface Config {
database: {
host: string;
port: number;
};
cache: {
enabled: boolean;
};
}
type ConfigPath = PropPath<Config>;
// "database.host" | "database.port" | "cache.enabled"Combining Advanced Types
Builder Pattern with Types
type Builder<T, Built = {}> = {
set<K extends keyof T>(
key: K,
value: T[K]
): Builder<Omit<T, K>, Built & Pick<T, K>>;
build(): Built extends T ? T : never;
};
interface User {
id: string;
name: string;
email: string;
}
declare function createBuilder<T>(): Builder<T>;
const user = createBuilder<User>()
.set("id", "123")
.set("name", "John")
.set("email", "john@example.com")
.build();Deep Readonly
type DeepReadonly<T> = T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;
interface NestedConfig {
server: {
port: number;
host: string;
};
features: string[];
}
type ImmutableConfig = DeepReadonly<NestedConfig>;
// All nested properties are readonlyType-Safe Object.keys
// Standard Object.keys returns string[]
// Create type-safe version
function typedKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}
const config = { port: 3000, host: "localhost" };
const keys = typedKeys(config); // ("port" | "host")[]Best Practices
1. Start Simple: Use basic generics before reaching for conditional types 2. Name Descriptively: TItem, TResult better than T, U 3. Constrain Generics: Use extends to limit acceptable types 4. Prefer Utility Types: Use built-in Partial, Required, Pick, Omit 5. Document Complex Types: Add JSDoc for non-obvious type logic 6. Test Types: Use @ts-expect-error to verify type behavior
Type Guards
Type guards narrow types at runtime while maintaining type safety. Use them to safely handle union types, unknown values, and discriminated unions.
Built-in Type Guards
typeof Guards
function processValue(value: string | number): string {
if (typeof value === "string") {
// value is string here
return value.toUpperCase();
}
// value is number here
return value.toFixed(2);
}
// typeof works for primitives
function handlePrimitive(value: unknown): void {
if (typeof value === "string") {
console.log(value.length);
} else if (typeof value === "number") {
console.log(value.toFixed(2));
} else if (typeof value === "boolean") {
console.log(value ? "yes" : "no");
} else if (typeof value === "function") {
console.log(value.name);
}
}instanceof Guards
class Dog {
bark(): void {
console.log("Woof!");
}
}
class Cat {
meow(): void {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat): void {
if (animal instanceof Dog) {
animal.bark(); // animal is Dog
} else {
animal.meow(); // animal is Cat
}
}
// Works with Error types
function handleError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (error instanceof TypeError) {
return `Type error: ${error.message}`;
}
return String(error);
}in Operator
interface Fish {
swim(): void;
}
interface Bird {
fly(): void;
}
function move(animal: Fish | Bird): void {
if ("swim" in animal) {
animal.swim(); // animal is Fish
} else {
animal.fly(); // animal is Bird
}
}Custom Type Guards
Type Predicate Functions
// Return type is `value is Type`
function isString(value: unknown): value is string {
return typeof value === "string";
}
function isNumber(value: unknown): value is number {
return typeof value === "number" && !Number.isNaN(value);
}
function isNonNull<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
// Usage
function process(value: unknown): void {
if (isString(value)) {
console.log(value.toUpperCase()); // value is string
}
}
// Filter with type guard
const values = [1, null, "hello", undefined, 42];
const nonNull = values.filter(isNonNull); // (string | number)[]Object Shape Guards
interface User {
id: string;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
return (
typeof obj.id === "string" &&
typeof obj.name === "string" &&
typeof obj.email === "string"
);
}
// Usage with unknown data
function processUserData(data: unknown): User | null {
if (isUser(data)) {
return data; // data is User
}
return null;
}Array Type Guards
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every((item) => typeof item === "string")
);
}
function isArrayOf<T>(
value: unknown,
guard: (item: unknown) => item is T
): value is T[] {
return Array.isArray(value) && value.every(guard);
}
// Usage
const data: unknown = ["a", "b", "c"];
if (isArrayOf(data, isString)) {
// data is string[]
console.log(data.join(", "));
}Discriminated Unions
Basic Pattern
// Each type has a literal "type" property (discriminant)
interface LoadingState {
type: "loading";
}
interface SuccessState<T> {
type: "success";
data: T;
}
interface ErrorState {
type: "error";
message: string;
}
type State<T> = LoadingState | SuccessState<T> | ErrorState;
function handleState<T>(state: State<T>): string {
switch (state.type) {
case "loading":
return "Loading...";
case "success":
return `Data: ${state.data}`; // state.data is accessible
case "error":
return `Error: ${state.message}`; // state.message is accessible
}
}Exhaustive Checking
// Use never to ensure all cases are handled
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// If a new shape is added, TypeScript will error here
return assertNever(shape);
}
}Result Type Pattern
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { success: false, error: "Division by zero" };
}
return { success: true, value: a / b };
}
// Usage
const result = divide(10, 2);
if (result.success) {
console.log(result.value); // result.value is number
} else {
console.error(result.error); // result.error is string
}Assertion Functions
Basic Assertions
// Assertion function signature: asserts condition
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message ?? "Assertion failed");
}
}
// Usage
function process(value: string | null): void {
assert(value !== null, "Value must not be null");
// value is string after assertion
console.log(value.toUpperCase());
}Type Assertions
// Assert that value is a specific type
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error("Invalid user object");
}
}
// Usage
function processData(data: unknown): void {
assertIsUser(data);
// data is User after assertion
console.log(data.name);
}Assertion with Message
function assertDefined<T>(
value: T,
name: string
): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`${name} must be defined`);
}
}
// Usage
function createUser(name: string | undefined, email: string | null): void {
assertDefined(name, "name");
assertDefined(email, "email");
// Both are now non-null
console.log(name.toUpperCase(), email.toLowerCase());
}Narrowing with Control Flow
Truthiness Narrowing
function printLength(str: string | null | undefined): void {
if (str) {
// str is string (truthy check eliminates null/undefined/empty)
console.log(str.length);
}
}
// Note: 0 and "" are falsy
function getValue(value: string | number | null): string {
// This incorrectly excludes 0 and ""
if (value) {
return String(value);
}
return "default";
}
// Better: explicit null check
function getValueSafe(value: string | number | null): string {
if (value !== null) {
return String(value);
}
return "default";
}Equality Narrowing
function compare(a: string | number, b: string | boolean): void {
if (a === b) {
// a and b are both string (the only common type)
console.log(a.toUpperCase(), b.toUpperCase());
}
}
function handleValue(value: string | number | null): void {
if (value === null) {
return;
}
// value is string | number
console.log(value);
}Best Practices
1. Prefer Type Guards over Type Assertions: Guards are runtime-safe 2. Use Discriminated Unions: Add a literal type or kind property 3. Implement Exhaustive Checks: Use assertNever in switch defaults 4. Keep Guards Simple: Complex logic should be in the guard function 5. Name Guards Clearly: isUser, isValid, hasProperty 6. Test Guard Functions: Verify they correctly identify types 7. Use Assertions for Invariants: Fail fast on programming errors
Utility Types
TypeScript provides built-in utility types for common type transformations. Use them to avoid repetitive type definitions.
Object Property Modifiers
Partial<T>
Make all properties optional:
interface User {
id: string;
name: string;
email: string;
}
// All properties become optional
type PartialUser = Partial<User>;
// { id?: string; name?: string; email?: string }
// Use case: Update functions
function updateUser(id: string, updates: Partial<User>): User {
const current = getUser(id);
return { ...current, ...updates };
}
updateUser("123", { name: "New Name" }); // Only update nameRequired<T>
Make all properties required:
interface Config {
host?: string;
port?: number;
ssl?: boolean;
}
// All properties become required
type RequiredConfig = Required<Config>;
// { host: string; port: number; ssl: boolean }
// Use case: Validate complete config
function validateConfig(config: Config): RequiredConfig {
return {
host: config.host ?? "localhost",
port: config.port ?? 3000,
ssl: config.ssl ?? false,
};
}Readonly<T>
Make all properties readonly:
interface State {
count: number;
items: string[];
}
type ImmutableState = Readonly<State>;
// { readonly count: number; readonly items: string[] }
// Note: Only shallow readonly
const state: ImmutableState = { count: 0, items: [] };
state.count = 1; // Error: readonly
state.items.push("x"); // OK: array itself is mutable
// For deep readonly, create custom type (see advanced-types.md)Property Selection
Pick<T, K>
Select specific properties:
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Only include specified properties
type PublicUser = Pick<User, "id" | "name" | "email">;
// { id: string; name: string; email: string }
// Use case: API responses (exclude sensitive data)
function toPublicUser(user: User): PublicUser {
return {
id: user.id,
name: user.name,
email: user.email,
};
}Omit<T, K>
Exclude specific properties:
interface User {
id: string;
name: string;
email: string;
password: string;
}
// Exclude specified properties
type UserWithoutPassword = Omit<User, "password">;
// { id: string; name: string; email: string }
// Use case: Create input (exclude generated fields)
type CreateUserInput = Omit<User, "id">;
function createUser(input: CreateUserInput): User {
return {
id: generateId(),
...input,
};
}Union Type Operations
Exclude<T, U>
Remove types from union:
type Status = "pending" | "active" | "deleted" | "archived";
// Remove "deleted" from union
type ActiveStatus = Exclude<Status, "deleted">;
// "pending" | "active" | "archived"
// Remove multiple types
type LiveStatus = Exclude<Status, "deleted" | "archived">;
// "pending" | "active"Extract<T, U>
Keep only matching types:
type Value = string | number | boolean | null | undefined;
// Extract only primitive types
type Primitive = Extract<Value, string | number | boolean>;
// string | number | boolean
// Extract nullable types
type Nullable = Extract<Value, null | undefined>;
// null | undefinedNonNullable<T>
Remove null and undefined:
type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>;
// string
// Use case: After null check
function processItems(items: string[] | null): void {
if (items === null) return;
// items is NonNullable<string[] | null> = string[]
items.forEach(console.log);
}Function Types
Parameters<T>
Extract function parameter types:
function createUser(name: string, email: string, age: number): User {
// ...
}
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, email: string, age: number]
// Use case: Wrapper functions
function logAndCreate(...args: CreateUserParams): User {
console.log("Creating user:", args);
return createUser(...args);
}ReturnType<T>
Extract function return type:
function fetchData(): Promise<{ id: string; data: unknown }> {
// ...
}
type FetchResult = ReturnType<typeof fetchData>;
// Promise<{ id: string; data: unknown }>
// Unwrap promise
type FetchData = Awaited<ReturnType<typeof fetchData>>;
// { id: string; data: unknown }ConstructorParameters<T>
Extract constructor parameter types:
class Database {
constructor(host: string, port: number, ssl: boolean) {
// ...
}
}
type DbParams = ConstructorParameters<typeof Database>;
// [host: string, port: number, ssl: boolean]InstanceType<T>
Extract instance type from class:
class UserService {
getUser(id: string): User {
// ...
}
}
type UserServiceInstance = InstanceType<typeof UserService>;
// UserService
// Use case: Dependency injection
function createController(service: InstanceType<typeof UserService>) {
return {
get: (id: string) => service.getUser(id),
};
}Object Types
Record<K, T>
Create object type with specific keys:
// Simple key-value mapping
type StringMap = Record<string, string>;
// { [key: string]: string }
// Specific keys
type RolePermissions = Record<"admin" | "user" | "guest", string[]>;
// { admin: string[]; user: string[]; guest: string[] }
// Use case: Status messages
type StatusMessages = Record<Status, string>;
const messages: StatusMessages = {
pending: "Waiting for approval",
active: "Currently active",
deleted: "Has been deleted",
archived: "Moved to archive",
};String Manipulation
Uppercase<S> / Lowercase<S>
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"Capitalize<S> / Uncapitalize<S>
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"
// Use case: Event handlers
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"Promise Types
Awaited<T>
Unwrap Promise types:
type PromiseString = Promise<string>;
type ResolvedString = Awaited<PromiseString>; // string
// Works with nested promises
type NestedPromise = Promise<Promise<number>>;
type ResolvedNumber = Awaited<NestedPromise>; // number
// Non-promise passes through
type PlainNumber = Awaited<number>; // numberThis Types
ThisParameterType<T>
Extract this parameter type:
function greet(this: { name: string }, greeting: string): string {
return `${greeting}, ${this.name}`;
}
type GreetThis = ThisParameterType<typeof greet>;
// { name: string }OmitThisParameter<T>
Remove this parameter:
function greet(this: { name: string }, greeting: string): string {
return `${greeting}, ${this.name}`;
}
type GreetWithoutThis = OmitThisParameter<typeof greet>;
// (greeting: string) => string
// Use case: Bind functions
const boundGreet: GreetWithoutThis = greet.bind({ name: "World" });Combining Utility Types
Common Patterns
// Create from existing (no id for creation)
interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface User extends Entity {
name: string;
email: string;
}
type CreateInput<T extends Entity> = Omit<T, keyof Entity>;
type UpdateInput<T extends Entity> = Partial<Omit<T, keyof Entity>>;
type CreateUserInput = CreateInput<User>;
// { name: string; email: string }
type UpdateUserInput = UpdateInput<User>;
// { name?: string; email?: string }Deep Partial
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
interface Config {
database: {
host: string;
port: number;
};
cache: {
enabled: boolean;
ttl: number;
};
}
type PartialConfig = DeepPartial<Config>;
// All nested properties are also optionalMutable (Remove Readonly)
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
interface ImmutableUser {
readonly id: string;
readonly name: string;
}
type MutableUser = Mutable<ImmutableUser>;
// { id: string; name: string }Best Practices
1. Use Built-in Types First: Before creating custom types 2. Combine for Complex Types: Chain utilities like Readonly<Partial<T>> 3. Name Derived Types: Create aliases for complex combinations 4. Document Purpose: Explain why a utility type is used 5. Keep It Simple: Avoid deeply nested utility types 6. Test Edge Cases: Verify behavior with optional/readonly properties
#!/usr/bin/env -S deno run --allow-read
/**
* TypeScript Code Analyzer
*
* Static analysis for TypeScript code quality issues.
* Detects common anti-patterns and suggests improvements.
*
* Usage:
* deno run --allow-read scripts/analyze.ts <path> [options]
*
* Options:
* --strict Enable all checks
* --json Output JSON for programmatic use
* --fix-hints Show suggested fixes
* -h, --help Show help
*/
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "analyze";
// === Types ===
interface AnalyzeOptions {
path: string;
strict: boolean;
json: boolean;
fixHints: boolean;
}
interface Issue {
severity: "critical" | "high" | "medium" | "low";
category: string;
message: string;
file: string;
line: number;
column: number;
code: string;
fix?: string;
}
interface AnalysisResult {
path: string;
filesAnalyzed: number;
issues: Issue[];
summary: {
critical: number;
high: number;
medium: number;
low: number;
};
}
// === Patterns to Detect ===
const PATTERNS: Array<{
name: string;
pattern: RegExp;
severity: Issue["severity"];
category: string;
message: string;
fix: string;
}> = [
{
name: "any-type",
pattern: /:\s*any\b(?!\s*\[)/g,
severity: "critical",
category: "Type Safety",
message: "Avoid using 'any' type - it disables type checking",
fix: "Use 'unknown' and narrow with type guards, or define a specific type",
},
{
name: "any-array",
pattern: /:\s*any\s*\[\]/g,
severity: "critical",
category: "Type Safety",
message: "Avoid using 'any[]' type",
fix: "Use 'unknown[]' or define a specific array type",
},
{
name: "non-null-assertion",
pattern: /\w+!/g,
severity: "high",
category: "Type Safety",
message: "Non-null assertion (!) can cause runtime errors",
fix: "Use optional chaining (?.) or add proper null checks",
},
{
name: "type-assertion-as",
pattern: /\bas\s+(?!const\b)\w+/g,
severity: "medium",
category: "Type Safety",
message: "Type assertions with 'as' bypass type checking",
fix: "Use type guards to narrow types safely",
},
{
name: "object-any",
pattern: /:\s*object\b/g,
severity: "medium",
category: "Type Safety",
message: "The 'object' type is too broad",
fix: "Define a specific interface or use Record<string, unknown>",
},
{
name: "Function-type",
pattern: /:\s*Function\b/g,
severity: "medium",
category: "Type Safety",
message: "The 'Function' type is too broad",
fix: "Define a specific function signature: (arg: Type) => ReturnType",
},
{
name: "implicit-any-param",
pattern: /\(\s*\w+\s*\)\s*=>/g,
severity: "medium",
category: "Type Safety",
message: "Arrow function parameter may have implicit 'any'",
fix: "Add explicit parameter type: (param: Type) =>",
},
{
name: "ts-ignore",
pattern: /@ts-ignore/g,
severity: "high",
category: "Type Safety",
message: "@ts-ignore suppresses all errors on the next line",
fix: "Use @ts-expect-error with explanation, or fix the type error",
},
{
name: "ts-nocheck",
pattern: /@ts-nocheck/g,
severity: "critical",
category: "Type Safety",
message: "@ts-nocheck disables type checking for entire file",
fix: "Remove @ts-nocheck and fix type errors properly",
},
{
name: "console-log",
pattern: /console\.(log|debug|info)\(/g,
severity: "low",
category: "Code Quality",
message: "Console statements should be removed in production",
fix: "Use a proper logging library or remove before commit",
},
{
name: "todo-comment",
pattern: /\/\/\s*(TODO|FIXME|HACK|XXX):/gi,
severity: "low",
category: "Code Quality",
message: "Unresolved TODO/FIXME comment",
fix: "Address the TODO or create an issue to track it",
},
{
name: "var-keyword",
pattern: /\bvar\s+\w+/g,
severity: "medium",
category: "Code Quality",
message: "'var' has function scope which can cause bugs",
fix: "Use 'const' for constants or 'let' for variables",
},
{
name: "triple-slash",
pattern: /\/\/\/\s*<reference/g,
severity: "low",
category: "Code Quality",
message: "Triple-slash references are outdated",
fix: "Use ES module imports instead",
},
{
name: "eval-usage",
pattern: /\beval\s*\(/g,
severity: "critical",
category: "Security",
message: "eval() is a security risk and prevents optimization",
fix: "Use safer alternatives like JSON.parse() or Function constructor",
},
{
name: "new-Function",
pattern: /new\s+Function\s*\(/g,
severity: "high",
category: "Security",
message: "new Function() is similar to eval() - security risk",
fix: "Define functions statically when possible",
},
{
name: "innerHTML",
pattern: /\.innerHTML\s*=/g,
severity: "high",
category: "Security",
message: "innerHTML can introduce XSS vulnerabilities",
fix: "Use textContent for text, or sanitize HTML input",
},
{
name: "async-no-await",
pattern: /async\s+(?:function\s+\w+|\w+\s*=\s*async)\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{[^}]*\}/g,
severity: "medium",
category: "Async",
message: "Async function may not contain await",
fix: "Remove async keyword if not needed, or add await",
},
{
name: "promise-constructor",
pattern: /new\s+Promise\s*\(\s*(?:async|function)/g,
severity: "medium",
category: "Async",
message: "Unnecessary Promise constructor (async executor or can be simplified)",
fix: "Use async/await directly instead of wrapping in new Promise",
},
{
name: "for-in-array",
pattern: /for\s*\(\s*(?:const|let|var)\s+\w+\s+in\s+\w+\)/g,
severity: "medium",
category: "Iteration",
message: "for...in iterates over all enumerable properties, not just indices",
fix: "Use for...of for arrays, or Object.keys/entries for objects",
},
{
name: "delete-operator",
pattern: /\bdelete\s+\w+(\.\w+|\[\w+\])/g,
severity: "low",
category: "Performance",
message: "delete operator can deoptimize objects",
fix: "Set property to undefined, or use object destructuring with rest",
},
{
name: "magic-number",
pattern: /(?<![\w.])(?:0|[1-9]\d{2,})(?!\d)/g,
severity: "low",
category: "Code Quality",
message: "Magic numbers reduce code readability",
fix: "Extract to a named constant with descriptive name",
},
];
// === File Discovery ===
async function findTypeScriptFiles(path: string): Promise<string[]> {
const files: string[] = [];
try {
const stat = await Deno.stat(path);
if (stat.isFile && (path.endsWith(".ts") || path.endsWith(".tsx"))) {
files.push(path);
} else if (stat.isDirectory) {
for await (const entry of Deno.readDir(path)) {
// Skip node_modules and hidden directories
if (entry.name.startsWith(".") || entry.name === "node_modules") {
continue;
}
const fullPath = `${path}/${entry.name}`;
if (entry.isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) {
// Skip .d.ts declaration files
if (!entry.name.endsWith(".d.ts")) {
files.push(fullPath);
}
} else if (entry.isDirectory) {
const subFiles = await findTypeScriptFiles(fullPath);
files.push(...subFiles);
}
}
}
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
console.error(`Error: Path not found: ${path}`);
Deno.exit(1);
}
throw error;
}
return files;
}
// === Analysis ===
function analyzeFile(
filePath: string,
content: string,
options: AnalyzeOptions
): Issue[] {
const issues: Issue[] = [];
const lines = content.split("\n");
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
const line = lines[lineNum];
// Skip comment-only lines for some checks
const trimmedLine = line.trim();
const isCommentLine = trimmedLine.startsWith("//") || trimmedLine.startsWith("/*");
for (const pattern of PATTERNS) {
// Skip low severity in non-strict mode
if (!options.strict && pattern.severity === "low") {
continue;
}
// Reset regex lastIndex
pattern.pattern.lastIndex = 0;
let match;
while ((match = pattern.pattern.exec(line)) !== null) {
// Skip certain patterns in comments
if (isCommentLine && !["todo-comment", "ts-ignore", "ts-nocheck"].includes(pattern.name)) {
continue;
}
// Skip non-null assertion false positives (! in strings, comments, etc.)
if (pattern.name === "non-null-assertion") {
// Check if it's part of !== or !=
const before = line.substring(0, match.index);
const after = line.substring(match.index + match[0].length);
if (before.endsWith("!") || after.startsWith("=")) {
continue;
}
// Check if inside string
const beforeQuotes = (before.match(/"/g) || []).length;
const beforeSingleQuotes = (before.match(/'/g) || []).length;
if (beforeQuotes % 2 === 1 || beforeSingleQuotes % 2 === 1) {
continue;
}
}
issues.push({
severity: pattern.severity,
category: pattern.category,
message: pattern.message,
file: filePath,
line: lineNum + 1,
column: match.index + 1,
code: match[0],
fix: options.fixHints ? pattern.fix : undefined,
});
}
}
}
return issues;
}
async function analyze(options: AnalyzeOptions): Promise<AnalysisResult> {
const files = await findTypeScriptFiles(options.path);
if (files.length === 0) {
console.error(`No TypeScript files found in: ${options.path}`);
Deno.exit(1);
}
const allIssues: Issue[] = [];
for (const file of files) {
const content = await Deno.readTextFile(file);
const issues = analyzeFile(file, content, options);
allIssues.push(...issues);
}
// Sort by severity
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
allIssues.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
const summary = {
critical: allIssues.filter((i) => i.severity === "critical").length,
high: allIssues.filter((i) => i.severity === "high").length,
medium: allIssues.filter((i) => i.severity === "medium").length,
low: allIssues.filter((i) => i.severity === "low").length,
};
return {
path: options.path,
filesAnalyzed: files.length,
issues: allIssues,
summary,
};
}
// === Output Formatting ===
function formatHumanOutput(result: AnalysisResult, showFixes: boolean): void {
console.log("\nTYPESCRIPT ANALYSIS REPORT");
console.log("==========================\n");
console.log(`Path: ${result.path}`);
console.log(`Files analyzed: ${result.filesAnalyzed}`);
console.log();
const total = result.summary.critical + result.summary.high + result.summary.medium + result.summary.low;
console.log("ISSUES BY SEVERITY");
console.log(` Critical: ${result.summary.critical}`);
console.log(` High: ${result.summary.high}`);
console.log(` Medium: ${result.summary.medium}`);
console.log(` Low: ${result.summary.low}`);
console.log(` Total: ${total}`);
console.log();
if (result.issues.length === 0) {
console.log("No issues found!");
return;
}
console.log("ISSUES:");
console.log();
for (const issue of result.issues) {
const severityLabel = `[${issue.severity.toUpperCase()}]`.padEnd(10);
console.log(`${severityLabel} ${issue.category}: ${issue.message}`);
console.log(` File: ${issue.file}:${issue.line}:${issue.column}`);
console.log(` Code: ${issue.code}`);
if (showFixes && issue.fix) {
console.log(` Fix: ${issue.fix}`);
}
console.log();
}
}
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - TypeScript Code Analyzer
Usage:
deno run --allow-read scripts/analyze.ts <path> [options]
Arguments:
<path> File or directory to analyze
Options:
--strict Enable all checks (including low severity)
--json Output JSON for programmatic use
--fix-hints Show suggested fixes for each issue
-h, --help Show this help
Examples:
# Analyze a single file
deno run --allow-read scripts/analyze.ts ./src/utils.ts
# Analyze a directory
deno run --allow-read scripts/analyze.ts ./src
# Strict mode with fix hints
deno run --allow-read scripts/analyze.ts ./src --strict --fix-hints
# JSON output for CI integration
deno run --allow-read scripts/analyze.ts ./src --json
Checks Performed:
Type Safety:
- any type usage
- Non-null assertions (!)
- Type assertions (as)
- Broad types (object, Function)
- @ts-ignore / @ts-nocheck
Code Quality:
- var keyword usage
- Console statements
- TODO/FIXME comments
- Magic numbers
Security:
- eval() usage
- innerHTML assignments
- new Function()
Async:
- Unnecessary Promise constructors
- Async functions without await
Iteration:
- for...in on arrays
`);
}
// === CLI Handler ===
function parseArgs(args: string[]): AnalyzeOptions | null {
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
return null;
}
const options: AnalyzeOptions = {
path: "",
strict: false,
json: false,
fixHints: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--strict") {
options.strict = true;
} else if (arg === "--json") {
options.json = true;
} else if (arg === "--fix-hints") {
options.fixHints = true;
} else if (!arg.startsWith("-")) {
options.path = arg;
}
}
if (!options.path) {
console.error("Error: Path is required");
return null;
}
return options;
}
// === Entry Point ===
async function main(): Promise<void> {
const options = parseArgs(Deno.args);
if (!options) {
printHelp();
Deno.exit(0);
}
const result = await analyze(options);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
formatHumanOutput(result, options.fixHints);
}
// Exit with error code if critical issues found
if (result.summary.critical > 0) {
Deno.exit(1);
}
}
if (import.meta.main) {
main();
}
Related skills
How it compares
Use typescript-best-practices for standing TypeScript idioms during agent codegen; pair with linters when you need enforced rule violations on CI.
FAQ
What problem does typescript-best-practices solve?
typescript-best-practices teaches agents to produce clean, idiomatic, maintainable TypeScript and avoid common anti-patterns when Claude, Cursor, or similar tools author or refactor .ts and .tsx files.
How popular is typescript-best-practices on skills.sh?
typescript-best-practices reports 494 installs on skills.sh from jwynia/agent-skills with rank 21, indicating heavy use as a TypeScript quality reference for agents.