Skip to main content

TypeScript Best Practices

Why TypeScript Matters

TypeScript provides compile-time type safety that catches entire classes of bugs before they reach production - null references, type mismatches, missing properties, and incorrect function calls. The type system acts as executable documentation that stays in sync with your code, making refactoring safer and enabling powerful IDE features like autocomplete and automated refactoring. The upfront investment in strong typing pays dividends in reduced runtime errors, improved code maintainability, and better developer experience.

Overview

TypeScript is mandatory for all frontend applications (React, Angular) and increasingly preferred for backend services (Node.js). This guide covers best practices for writing type-safe, maintainable TypeScript code. We focus on leveraging TypeScript's type system to catch bugs at compile time, encode business rules in types, and create self-documenting code that's easier to maintain and refactor.


Core Principles

  1. Strict Mode Always: Enable all strict TypeScript compiler options
  2. Type Safety Over Convenience: Never use any; prefer unknown or proper types
  3. Immutability by Default: Use readonly and const extensively
  4. Explicit is Better Than Implicit: Annotate function parameters and return types
  5. Validate External Data: Types don't exist at runtime; validate API responses and user input
  6. Domain-Driven Types: Model business concepts accurately in the type system

TypeScript Configuration

Strict tsconfig.json

The TypeScript compiler configuration (tsconfig.json) is critical for enforcing type safety across your project. A properly configured tsconfig.json catches bugs at compile time and provides better IDE support.

{
"compilerOptions": {
// Language & Environment
// target: Specifies ECMAScript version for output (ES2022 provides modern features)
"target": "ES2022",
// lib: Type definitions for runtime APIs (DOM for browser, ES2022 for modern JS)
"lib": ["ES2022", "DOM", "DOM.Iterable"],
// module: Module system for output (ESNext for modern bundlers)
"module": "ESNext",
// moduleResolution: How TypeScript resolves module imports
"moduleResolution": "bundler",
// jsx: JSX transformation strategy (react-jsx for React 17+)
"jsx": "react-jsx",

// Strict Type Checking (CRITICAL)
// strict: Enables all strict type checking options at once
"strict": true,
// noImplicitAny: Error on expressions with implied 'any' type
"noImplicitAny": true,
// strictNullChecks: null/undefined only assignable to themselves or any
"strictNullChecks": true,
// strictFunctionTypes: Stricter checking of function parameter compatibility
"strictFunctionTypes": true,
// strictBindCallApply: Check bind/call/apply arguments match function signature
"strictBindCallApply": true,
// strictPropertyInitialization: Class properties must be initialized
"strictPropertyInitialization": true,
// noImplicitThis: Error when 'this' has type 'any'
"noImplicitThis": true,
// alwaysStrict: Parse in strict mode and emit "use strict"
"alwaysStrict": true,

// Additional Checks
// noUnusedLocals: Error on unused local variables
"noUnusedLocals": true,
// noUnusedParameters: Error on unused function parameters
"noUnusedParameters": true,
// noImplicitReturns: Error if not all code paths return a value
"noImplicitReturns": true,
// noFallthroughCasesInSwitch: Error on fallthrough cases in switch
"noFallthroughCasesInSwitch": true,
// noUncheckedIndexedAccess: Add undefined to indexed access (array[0] is T | undefined)
"noUncheckedIndexedAccess": true,
// noImplicitOverride: Require 'override' keyword for class methods
"noImplicitOverride": true,
// noPropertyAccessFromIndexSignature: Require bracket notation for index signatures
"noPropertyAccessFromIndexSignature": true,

// Module Resolution
// baseUrl: Base directory for resolving non-relative module names
"baseUrl": ".",
// paths: Path mapping for cleaner imports
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@services/*": ["src/services/*"],
"@types/*": ["src/types/*"],
"@utils/*": ["src/utils/*"]
},
// resolveJsonModule: Allow importing JSON files as modules
"resolveJsonModule": true,
// esModuleInterop: Interoperability between CommonJS and ES Modules
"esModuleInterop": true,

// Output
// outDir: Output directory for compiled files
"outDir": "./dist",
// declaration: Generate .d.ts declaration files for type checking
"declaration": true,
// declarationMap: Generate sourcemaps for declaration files
"declarationMap": true,
// sourceMap: Generate sourcemaps for debugging
"sourceMap": true,
// removeComments: Keep comments in output (useful for documentation)
"removeComments": false,

// Other
// skipLibCheck: Skip type checking of declaration files (faster builds)
"skipLibCheck": true,
// forceConsistentCasingInFileNames: Error on inconsistent file name casing
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}

Key Configuration Decisions:

  • "strict": true: This is the single most important setting. It enables all strict type checking flags at once. Never disable strict mode - if you're fighting the compiler, your code likely has real bugs that need fixing.

  • "noUncheckedIndexedAccess": true: This makes array and object indexing safer by adding undefined to the type (e.g., array[0] becomes T | undefined). This reflects runtime reality - array access can fail.

  • Path Mappings: Using @/* aliases keeps imports clean and makes refactoring easier. Without them, you get messy relative paths like ../../../utils/validation.

  • "moduleResolution": "bundler": Use this with modern bundlers (Vite, webpack 5+). It provides better tree-shaking and module resolution.

Critical Requirement

ALL projects MUST use "strict": true. No exceptions. Relaxing strict mode to "fix" type errors indicates deeper design problems that will manifest as runtime bugs. Address the root cause instead.


Type System Fundamentals

Primitive Types

TypeScript provides type annotations for JavaScript's primitive types: string, number, boolean, null, undefined, symbol, and bigint. Always annotate function parameters and return types explicitly - type inference is excellent for local variables, but explicit annotations serve as executable documentation and catch errors at API boundaries.

// GOOD: Explicit primitive types
// The function signature documents what it expects and returns
// TypeScript will error if you pass wrong types or use the result incorrectly
function processPayment(
amount: number,
currency: string,
accountId: string
): boolean {
// Implementation
return true;
}

// BAD: Implicit any
// Parameters have type 'any' - no type checking at all
// You lose all benefits of TypeScript
function processPayment(amount, currency, accountId) {
// TypeScript won't catch: processPayment('not a number', 123, null)
// No autocomplete in IDE, no refactoring support
}

Why Explicit Annotations Matter:

  1. Early Error Detection: processPayment("100", "USD", "ACC-123") will error at compile time - the first argument must be number, not string.

  2. Self-Documentation: The signature tells you exactly what the function needs without reading the implementation or documentation.

  3. Safe Refactoring: If you change the signature, TypeScript finds all call sites that need updating.

  4. IDE Support: Your editor provides accurate autocomplete, inline documentation, and error highlighting.

Interfaces vs Types

TypeScript provides two ways to define object shapes: interface and type aliases. While they overlap significantly, each has specific use cases where it excels.

//  Use interfaces for object shapes (extendable)
// Interfaces can be extended and merged, making them ideal for:
// - Data models and domain objects
// - API contracts that might be extended
// - Public APIs in libraries
interface Payment {
readonly id: string;
readonly amount: number;
readonly currency: string;
readonly accountId: string;
readonly createdAt: Date;
status: PaymentStatus; // Mutable (no readonly modifier)
}

// Interface extension example
interface DetailedPayment extends Payment {
description: string;
metadata: Record<string, unknown>;
}

// Use types for unions, intersections, utilities
// Type aliases can represent any type, not just objects:
// - Union types (A | B)
// - Intersection types (A & B)
// - Mapped types and utilities
type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed';
type PaymentResult = SuccessResult | FailureResult;
type PartialPayment = Partial<Payment>;
type ReadonlyPayment = Readonly<Payment>;

// Type aliases for complex types
// Function types and complex compositions
type PaymentHandler = (payment: Payment) => Promise<PaymentResult>;
type PaymentValidator = (payment: Partial<Payment>) => ValidationResult;

Choosing Between Interface and Type:

  • Use interface when defining object shapes that represent domain models or when you need declaration merging (rare but useful for extending third-party types).

  • Use type for unions, intersections, mapped types, conditional types, or when you need to alias primitive types or tuples.

  • Consistency Matters: Pick one primary approach for your codebase. Many teams use interfaces for data models and types for everything else.

Enums vs String Literals

TypeScript offers both enums and string literal unions for representing fixed sets of values. String literal unions are generally preferred because they're simpler, have no runtime overhead, and serialize naturally to JSON.

// GOOD: String literal union (preferred for most cases)
// This is pure type information - no JavaScript is generated
// Serializes naturally to JSON as strings
// Works seamlessly with APIs that expect string values
type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed';

function updateStatus(payment: Payment, status: PaymentStatus): void {
// TypeScript ensures status is one of the four valid values
// At runtime, it's just a string - no enum object to import
}

// Usage is straightforward and type-safe
updateStatus(payment, 'completed'); //
updateStatus(payment, 'invalid'); // Compile error

// GOOD: Const enum for performance-critical code
// Const enums are inlined at compile time (no runtime object created)
// Use when you need named constants but want zero runtime cost
const enum HttpMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE'
}

// At compile time, HttpMethod.GET is replaced with 'GET' (inlined)
const method = HttpMethod.GET; // Becomes: const method = 'GET';

// AVOID: Regular enums (generate extra code)
// Regular enums generate a JavaScript object at runtime
// This adds bundle size and complexity
enum PaymentStatus {
Pending = 'pending',
Processing = 'processing',
Completed = 'completed',
Failed = 'failed'
}
// Generates ~10 lines of JavaScript for the enum object

Why Prefer String Literal Unions:

  1. No Runtime Cost: String literals are pure type information - they disappear at compile time.

  2. JSON Compatibility: They serialize naturally as strings, perfect for APIs.

  3. Simpler Mental Model: They're just strings with compile-time checking.

  4. Better Tree-Shaking: No enum object means bundlers have less to optimize away.

When to Use Enums

Use const enum when you need named constants with zero runtime overhead. Use regular enums only when you need reverse lookup (enum[value] → key) or when values are purely internal. For API contracts, always use string literal unions.


Advanced Type Patterns

Type Guards

Type guards are runtime checks that narrow TypeScript's understanding of a type. They allow you to safely handle union types by proving to the compiler which specific type you're working with.

// Discriminated unions with type guards
// The 'type' property acts as a discriminator - a literal value that distinguishes variants
interface SuccessResult {
readonly type: 'success'; // Literal type, not just string
readonly transactionId: string;
readonly amount: number;
}

interface FailureResult {
readonly type: 'failure'; // Different literal type
readonly error: string;
readonly code: string;
}

// Union type representing either success or failure
type PaymentResult = SuccessResult | FailureResult;

// Type guard function with 'is' predicate
// The return type 'result is SuccessResult' tells TypeScript this is a type guard
// If this function returns true, TypeScript knows result is SuccessResult
function isSuccess(result: PaymentResult): result is SuccessResult {
return result.type === 'success';
}

// Usage demonstrates type narrowing
function handlePaymentResult(result: PaymentResult): void {
// Before the if, result could be SuccessResult or FailureResult
if (isSuccess(result)) {
// After the guard returns true, TypeScript knows result is SuccessResult
// You can safely access transactionId and amount
console.log(`Transaction ID: ${result.transactionId}`);
console.log(`Amount: ${result.amount}`);
// result.error // Error: Property 'error' doesn't exist on SuccessResult
} else {
// In the else branch, TypeScript knows result is FailureResult
// You can safely access error and code
console.error(`Error: ${result.error} (${result.code})`);
// result.transactionId // Error: Property 'transactionId' doesn't exist on FailureResult
}
}

// Discriminated unions work without explicit type guard functions
function handlePaymentResultDirect(result: PaymentResult): void {
// TypeScript understands discriminated unions automatically
if (result.type === 'success') {
// TypeScript narrows to SuccessResult
console.log(result.transactionId);
} else {
// TypeScript narrows to FailureResult
console.error(result.error);
}
}

How Type Guards Work:

  1. Discriminated Unions: The type property acts as a discriminator. TypeScript uses its value to determine which variant you're working with.

  2. Type Narrowing: After the guard check, TypeScript narrows the type in that code branch, enabling safe access to variant-specific properties.

  3. Exhaustiveness Checking: If you add a third variant (e.g., PendingResult), TypeScript will error in any switch/if that doesn't handle all cases.

Built-in Type Guards:

// typeof for primitives
if (typeof value === 'string') {
// value is string here
}

// instanceof for class instances
if (value instanceof Date) {
// value is Date here
}

// in operator for property checks
if ('transactionId' in result) {
// result has transactionId property
}

Utility Types

// Payment type
interface Payment {
id: string;
amount: number;
currency: string;
accountId: string;
vendorId: string;
status: PaymentStatus;
createdAt: Date;
updatedAt: Date;
}

// Partial for updates
type PaymentUpdate = Partial<Payment>;

function updatePayment(id: string, updates: PaymentUpdate): void {
// Only id and selected fields required
}

// Pick for selecting fields
type PaymentSummary = Pick<Payment, 'id' | 'amount' | 'status'>;

// Omit for excluding fields
type CreatePaymentRequest = Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>;

// Required for making all fields required
type RequiredPayment = Required<Payment>;

// Readonly for immutability
type ImmutablePayment = Readonly<Payment>;

// Record for maps
type PaymentsByStatus = Record<PaymentStatus, Payment[]>;

const groupedPayments: PaymentsByStatus = {
pending: [],
processing: [],
completed: [],
failed: []
};

Conditional Types

// Extract async function return type
type AsyncReturnType<T> = T extends (...args: any[]) => Promise<infer R>
? R
: never;

async function fetchPayment(id: string): Promise<Payment> {
// Implementation
}

type PaymentType = AsyncReturnType<typeof fetchPayment>; // Payment

// Extract array element type
type ArrayElement<T> = T extends (infer E)[] ? E : never;

type PaymentArray = Payment[];
type SinglePayment = ArrayElement<PaymentArray>; // Payment

Template Literal Types

// Build API route types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Resource = 'payments' | 'accounts' | 'transactions';

type ApiRoute = `/${Resource}`;
type ApiRouteWithId = `/${Resource}/:id`;

// Usage
const route: ApiRoute = '/payments'; // Valid
const routeWithId: ApiRouteWithId = '/payments/:id'; // Valid

// Build event names
type Entity = 'payment' | 'account' | 'transaction';
type Action = 'created' | 'updated' | 'deleted';
type EventName = `${Entity}.${Action}`;

const event: EventName = 'payment.created'; // Valid

Null Safety

Strict Null Checks

When strictNullChecks is enabled (which it should be via strict: true), TypeScript treats null and undefined as distinct types rather than values that can be assigned to anything. This prevents the notorious "billion dollar mistake" of null reference errors.

// GOOD: Handle null/undefined explicitly
// The return type explicitly includes null in the union
// This forces callers to handle the null case
function getPayment(id: string): Payment | null {
const payment = findPaymentById(id);
// Nullish coalescing (??) returns right side if left is null/undefined
return payment ?? null;
}

function processPayment(payment: Payment | null): void {
// Explicit null check narrows the type
if (payment === null) {
throw new Error('Payment not found');
}
// After the null check, TypeScript knows payment is non-null
// Safe to access properties without runtime error
console.log(payment.amount);
}

// GOOD: Optional chaining
// ?. short-circuits and returns undefined if payment is null/undefined
// No null reference error, returns undefined safely
function getPaymentAmount(payment: Payment | null): number | undefined {
return payment?.amount;
}

// GOOD: Nullish coalescing
// Combines optional chaining with default value
// If payment is null/undefined, or if currency is undefined, returns 'USD'
function getPaymentCurrency(payment: Payment | null): string {
return payment?.currency ?? 'USD';
}

// BAD: Non-null assertion (avoid!)
// The ! tells TypeScript "trust me, this isn't null"
// If you're wrong, you get a runtime error
// This defeats the purpose of TypeScript's null safety
function dangerousAccess(payment: Payment | null): number {
return payment!.amount; // Runtime error if payment is null!
// TypeScript can't help you here - you've explicitly told it to trust you
}

Key Null Safety Concepts:

  1. Union with null: Payment | null means the value might be null. TypeScript forces you to handle both cases.

  2. Optional Chaining (?.): Safely access nested properties. obj?.prop?.nested returns undefined if any part of the chain is null/undefined.

  3. Nullish Coalescing (??): Provides default values. Unlike ||, it only falls back for null/undefined, not for falsy values like 0 or ''.

  4. Non-null Assertion (!): Tells TypeScript "I know better." Almost always wrong - if you need it, your types are probably incorrect.

Why Strict Null Checks Matter:

Without strict null checks, this code compiles but crashes at runtime:

// With strictNullChecks: false (BAD)
function getAmount(payment: Payment | null): number {
return payment.amount; // Compiles fine, crashes if payment is null
}

// With strictNullChecks: true (GOOD)
function getAmount(payment: Payment | null): number {
return payment.amount; // Compile error: Object is possibly 'null'
// You're forced to handle null explicitly
}

Optional Properties

// GOOD: Explicit optional properties
interface PaymentMetadata {
readonly reference?: string;
readonly description?: string;
readonly tags?: string[];
}

function createPayment(
amount: number,
metadata?: PaymentMetadata
): Payment {
return {
id: generateId(),
amount,
currency: 'USD',
reference: metadata?.reference,
description: metadata?.description ?? 'No description',
tags: metadata?.tags ?? []
};
}

Functions and Type Safety

Function Signatures

// GOOD: Explicit parameter and return types
function calculateTotal(
payments: Payment[],
currency: string
): number {
return payments
.filter(p => p.currency === currency)
.reduce((sum, p) => sum + p.amount, 0);
}

// GOOD: Async functions
async function fetchPayment(id: string): Promise<Payment> {
const response = await fetch(`/api/payments/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch payment: ${response.statusText}`);
}
return response.json();
}

// GOOD: Optional parameters with defaults
function createPaymentRequest(
amount: number,
currency: string = 'USD',
metadata?: PaymentMetadata
): CreatePaymentRequest {
return { amount, currency, metadata };
}

// GOOD: Rest parameters
function processPayments(...payments: Payment[]): PaymentResult[] {
return payments.map(p => process(p));
}

Function Overloads

// Overload signatures
function findPayments(id: string): Payment | null;
function findPayments(ids: string[]): Payment[];
function findPayments(query: PaymentQuery): Payment[];

// Implementation signature
function findPayments(
param: string | string[] | PaymentQuery
): Payment | Payment[] | null {
if (typeof param === 'string') {
return findPaymentById(param);
}
if (Array.isArray(param)) {
return findPaymentsByIds(param);
}
return findPaymentsByQuery(param);
}

// Usage (fully type-safe)
const single = findPayments('PAY-123'); // Payment | null
const multiple = findPayments(['PAY-1', 'PAY-2']); // Payment[]
const queried = findPayments({ status: 'completed' }); // Payment[]

Immutability and Readonly

Readonly Properties

// GOOD: Immutable data structures
interface Payment {
readonly id: string;
readonly amount: number;
readonly currency: string;
readonly createdAt: Date;
status: PaymentStatus; // Only status is mutable
}

// GOOD: Readonly arrays
function getPaymentIds(payments: readonly Payment[]): readonly string[] {
return payments.map(p => p.id);
}

// BAD: Mutation
function invalidMutation(payments: readonly Payment[]): void {
payments.push(newPayment); // Error: Property 'push' does not exist
}

Deep Readonly

// Utility type for deep immutability
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};

interface PaymentWithMetadata {
id: string;
amount: number;
metadata: {
reference: string;
tags: string[];
};
}

type ImmutablePayment = DeepReadonly<PaymentWithMetadata>;

// All properties deeply readonly
const payment: ImmutablePayment = {
id: 'PAY-123',
amount: 100,
metadata: {
reference: 'REF-456',
tags: ['urgent']
}
};

// All these fail at compile time:
// payment.id = 'PAY-456'; // Error
// payment.metadata.reference = 'REF-789'; // Error
// payment.metadata.tags.push('normal'); // Error

Error Handling

Type-Safe Errors

// GOOD: Custom error classes
class PaymentError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly paymentId?: string
) {
super(message);
this.name = 'PaymentError';
}
}

class InsufficientBalanceError extends PaymentError {
constructor(
public readonly accountId: string,
public readonly required: number,
public readonly available: number
) {
super(
`Insufficient balance: required ${required}, available ${available}`,
'INSUFFICIENT_BALANCE'
);
this.name = 'InsufficientBalanceError';
}
}

// Usage
function processPayment(payment: Payment): PaymentResult {
try {
// Process payment
return { type: 'success', transactionId: 'TXN-123', amount: payment.amount };
} catch (error) {
if (error instanceof InsufficientBalanceError) {
// Type-safe error handling
return {
type: 'failure',
error: error.message,
code: error.code
};
}
throw error; // Re-throw unknown errors
}
}

Result Type Pattern

//  BETTER: Result type (no exceptions)
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };

function processPaymentSafe(payment: Payment): Result<PaymentResult, PaymentError> {
if (payment.amount <= 0) {
return {
ok: false,
error: new PaymentError('Invalid amount', 'INVALID_AMOUNT', payment.id)
};
}

return {
ok: true,
value: { type: 'success', transactionId: 'TXN-123', amount: payment.amount }
};
}

// Usage
const result = processPaymentSafe(payment);
if (result.ok) {
console.log('Success:', result.value.transactionId);
} else {
console.error('Error:', result.error.message);
}

Runtime Validation

Zod for API Validation

Critical Concept: TypeScript types only exist at compile time - they're erased when your code runs. This means you cannot trust that data from external sources (APIs, user input, localStorage) matches your types. You must validate at runtime.

import { z } from 'zod';

// Define schema - this exists at runtime, unlike TypeScript types
// Zod schemas are both validators AND type definitions
const PaymentSchema = z.object({
id: z.string().uuid(), // Must be a valid UUID string
amount: z.number().positive(), // Must be a positive number
currency: z.string().length(3), // Exactly 3 characters (e.g., USD, EUR)
accountId: z.string().min(1), // Non-empty string
vendorId: z.string().min(1),
status: z.enum(['pending', 'processing', 'completed', 'failed']), // One of four values
createdAt: z.coerce.date() // Coerce string/number to Date object
});

// Infer TypeScript type from schema
// This ensures your types and validation stay in sync
// If you update the schema, the type automatically updates
type Payment = z.infer<typeof PaymentSchema>;

// Validate API response
async function fetchPayment(id: string): Promise<Payment> {
const response = await fetch(`/api/payments/${id}`);
const data = await response.json();

// data has type 'any' from JSON.parse - could be anything!
// Runtime validation ensures it matches our Payment type
// parse() throws ZodError if validation fails
return PaymentSchema.parse(data);
}

// Safe parse (returns result instead of throwing)
// Prefer this for user input or when you want to handle errors gracefully
function parsePayment(data: unknown): Result<Payment, z.ZodError> {
const result = PaymentSchema.safeParse(data);
if (result.success) {
// result.data is guaranteed to match Payment type
return { ok: true, value: result.data };
}
// result.error contains detailed validation error information
return { ok: false, error: result.error };
}

Why Runtime Validation is Essential:

//  DANGEROUS: Trusting external data
async function fetchPaymentUnsafe(id: string): Promise<Payment> {
const response = await fetch(`/api/payments/${id}`);
const data = await response.json();

// Type assertion - you're lying to TypeScript
// If the API returns unexpected data, your app crashes
return data as Payment;
}

// SAFE: Validating external data
async function fetchPaymentSafe(id: string): Promise<Payment> {
const response = await fetch(`/api/payments/${id}`);
const data = await response.json();

// Zod validates at runtime
// If data doesn't match schema, throws clear error
// If it does match, you get type-safe Payment object
return PaymentSchema.parse(data);
}

Benefits of Zod:

  1. Single Source of Truth: Define validation logic once, infer types from it. No duplicate type definitions.

  2. Runtime Safety: Catches invalid data at the boundary (API calls, user input) before it corrupts your application state.

  3. Clear Error Messages: Zod provides detailed error messages showing exactly what failed validation.

  4. Type Inference: z.infer<typeof Schema> automatically generates TypeScript types from your schema.

See API Integration - Frontend for OpenAPI client generation with runtime validation.


Linting and Static Analysis

TypeScript's type system catches many errors at compile time, but linting and static analysis tools enforce code quality, consistent style, and catch additional issues beyond type errors. A comprehensive linting setup includes ESLint for code quality, Prettier for formatting, type-aware rules for deep analysis, and pre-commit hooks for immediate feedback.

For comprehensive coverage of linting tools, configuration, CI/CD integration, and best practices, see our dedicated TypeScript Linting and Static Analysis guide.

Quick reference:

  • ESLint - Code quality and pattern detection with TypeScript-specific rules
  • Prettier - Automated code formatting
  • Type-aware linting - Leverage TypeScript's type checker for deeper analysis
  • Husky + lint-staged - Pre-commit hooks for immediate feedback
  • CI/CD integration - Quality gates in pipelines
  • Custom rules - Team-specific standards enforcement

Common Anti-Patterns

1. Using any

// BAD: Defeats purpose of TypeScript
function processData(data: any): any {
return data.value.amount; // No type safety!
}

// GOOD: Use unknown and type guards
function processDataSafe(data: unknown): number {
if (isPayment(data)) {
return data.amount; // Type-safe
}
throw new Error('Invalid data');
}

function isPayment(data: unknown): data is Payment {
return (
typeof data === 'object' &&
data !== null &&
'amount' in data &&
typeof data.amount === 'number'
);
}

2. Type Assertions

// BAD: Unsafe type assertion
const payment = apiResponse as Payment;

// GOOD: Runtime validation
const payment = PaymentSchema.parse(apiResponse);

3. Ignoring Null/Undefined

// BAD: Assuming non-null
function getAmount(payment: Payment | null): number {
return payment.amount; // Runtime error if null!
}

// GOOD: Handle null explicitly
function getAmountSafe(payment: Payment | null): number {
if (payment === null) {
throw new Error('Payment is null');
}
return payment.amount;
}

// BETTER: Return optional
function getAmountOptional(payment: Payment | null): number | null {
return payment?.amount ?? null;
}

Project Structure

src/
├── types/ # Shared type definitions
│ ├── payment.ts
│ ├── account.ts
│ └── index.ts
├── services/ # Business logic
│ └── payment-service.ts
├── api/ # API clients
│ ├── payment-api.ts
│ └── schemas/ # Zod schemas
│ └── payment-schema.ts
├── components/ # UI components
│ └── PaymentForm.tsx
└── utils/ # Utilities
├── type-guards.ts
└── validation.ts

Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways

  1. Enable strict mode with all compiler checks; never relax for convenience
  2. Annotate function parameters and returns explicitly; avoid implicit any
  3. Use interfaces for objects, types for unions and intersections
  4. Leverage utility types (Partial, Pick, Omit, Record) for type transformations
  5. Handle null/undefined explicitly with optional chaining and nullish coalescing
  6. Never use any; use unknown and type guards instead
  7. Validate external data at runtime with Zod or similar libraries
  8. Prefer immutability with readonly and const; make mutation explicit
  9. Use discriminated unions with type guards for type-safe branching
  10. Custom error classes provide better type safety than throwing strings

Next Steps: Review TypeScript Testing for testing type-safe code and TypeScript Types for advanced type patterns.