TypeScript Best Practices: A Developer's Guide for 2026

From strict mode configuration to advanced patterns like branded types and template literal types — the TypeScript practices that separate production-ready code from prototype-quality guesswork.

Enable Strict Mode from Day One

The single highest-impact change you can make in any TypeScript project is enabling strict mode. One flag in tsconfig.json activates a suite of compiler checks that catch bugs before they reach runtime. Without it, TypeScript silently permits patterns that defeat the purpose of using a type system in the first place.

tsconfig.json — Strict mode
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

The "strict": true flag is shorthand for enabling all of the following:

Migrating an Existing Codebase?

If your project does not have strict mode yet, enable the flags one at a time. Start with strictNullChecks (catches the most bugs), then noImplicitAny, then the rest. Fix errors per flag before enabling the next. The TypeScript Playground is useful for testing how strict mode changes affect specific code snippets.

Additional Recommended Flags

Beyond strict mode, these flags tighten safety further:

tsconfig.json — Extra safety
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "noPropertyAccessFromIndexSignature": true
  }
}

noUncheckedIndexedAccess is particularly valuable. Without it, accessing array[0] returns T even if the array might be empty. With it, the return type is T | undefined, forcing you to handle the missing-element case.

Master the Built-in Utility Types

TypeScript ships with over a dozen utility types that transform existing types without rewriting them. Using these instead of manual type definitions keeps your code DRY and makes refactors propagate automatically.

Utility Type What It Does Use Case
Partial<T> All properties become optional Patch/update payloads
Required<T> All properties become required Validated/complete objects
Pick<T, K> Select specific properties API response subsets
Omit<T, K> Remove specific properties Removing internal fields
Record<K, V> Object with keys K and values V Lookup maps, dictionaries
Readonly<T> All properties become readonly Immutable state, configs
ReturnType<T> Extract function return type Inferring from existing functions
Awaited<T> Unwrap Promise types Async function results

Composing Utility Types

The real power emerges when you combine them. Instead of defining separate types for every variation of your domain model, derive them:

typescript — Composing utility types
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: Date;
  updatedAt: Date;
}

// Create payload — no id or timestamps
type CreateUserPayload = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;

// Update payload — partial, no id
type UpdateUserPayload = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;

// API list response — only summary fields
type UserSummary = Pick<User, 'id' | 'name' | 'role'>;

// Admin view — everything readonly
type UserReadonly = Readonly<User>;

When the User interface changes, every derived type updates automatically. No manual synchronization required. If you work with JSON API responses, the JSON to TypeScript Converter can generate initial interfaces from your actual API payloads.

Write Better Generics

Generics let functions and types work with multiple types while preserving the relationship between inputs and outputs. The key is knowing when they add value and when they add noise.

Good: Preserving Relationships

typescript — Generics that add value
// Without generics: returns unknown, caller must cast
function getFirst(arr: unknown[]): unknown {
  return arr[0];
}

// With generics: return type matches input type
function getFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = getFirst([1, 2, 3]);       // number | undefined
const str = getFirst(['a', 'b', 'c']); // string | undefined

Generic Constraints

Use extends to restrict what types the generic accepts. This gives you access to properties of the constraint while keeping the function generic:

typescript — Constrained generics
interface HasId {
  id: string;
}

function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

// Works with any object that has an id property
const user = findById(users, '123');   // User | undefined
const order = findById(orders, '456'); // Order | undefined

keyof with Generics

typescript — Type-safe property access
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key]);
}

const names = pluck(users, 'name');  // string[]
const roles = pluck(users, 'role');  // ('admin' | 'editor' | 'viewer')[]
// pluck(users, 'banana');           // Error: 'banana' not in keyof User
Anti-Pattern: Unnecessary Generics

If the generic parameter appears only once in the signature, you probably do not need it. function log<T>(value: T): void gains nothing over function log(value: unknown): void because the generic is never reused to create a relationship.

Type Guards and Narrowing

TypeScript narrows types inside conditional branches. When you check typeof x === 'string', the compiler knows x is a string inside that block. You can create custom narrowing logic with type predicates.

Built-in Narrowing

typescript — typeof and instanceof
function formatValue(value: string | number | Date): string {
  if (typeof value === 'string') {
    return value.toUpperCase();          // string
  }
  if (typeof value === 'number') {
    return value.toFixed(2);             // number
  }
  return value.toISOString();            // Date
}

Custom Type Guards

For complex types that typeof and instanceof cannot distinguish, write a function that returns a type predicate:

typescript — Custom type guard
interface ApiError {
  code: number;
  message: string;
}

interface ApiSuccess<T> {
  data: T;
}

type ApiResponse<T> = ApiError | ApiSuccess<T>;

function isApiError(response: ApiResponse<unknown>): response is ApiError {
  return 'code' in response && 'message' in response;
}

// Usage
const result: ApiResponse<User[]> = await fetchUsers();

if (isApiError(result)) {
  console.error(result.message); // TypeScript knows this is ApiError
} else {
  console.log(result.data);      // TypeScript knows this is ApiSuccess<User[]>
}

Type guards are especially useful when parsing JSON from APIs. You can validate the structure at runtime and get compile-time safety for everything downstream. The JSON Schema Generator can create validation schemas from sample API responses, which you can then pair with type guard functions.

Discriminated Unions for State Machines

A discriminated union is a union of types that share a common literal property (the discriminant). TypeScript uses that property to narrow the type inside conditional branches. This pattern is the cleanest way to model states that carry different data.

typescript — Discriminated union
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function renderUser(state: RequestState<User>) {
  switch (state.status) {
    case 'idle':
      return 'Click to load';
    case 'loading':
      return 'Loading...';
    case 'success':
      return state.data.name;  // TypeScript knows data exists
    case 'error':
      return state.error;      // TypeScript knows error exists
  }
}

Exhaustiveness Checking

Add a never check to guarantee that every variant is handled. If someone adds a new variant to the union, the compiler reports an error at the default case:

typescript — Exhaustiveness with never
function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}

function handleState(state: RequestState<User>): string {
  switch (state.status) {
    case 'idle':    return 'Idle';
    case 'loading': return 'Loading';
    case 'success': return state.data.name;
    case 'error':   return state.error;
    default:        return assertNever(state);
    // If a new variant is added and not handled above,
    // TypeScript errors here because state is not 'never'
  }
}
When to Use Discriminated Unions

Use them for any data that can be in one of several states with different shapes: API response states, form validation results, payment statuses, notification types, or navigation routes. They replace boolean flag combinations (isLoading && !hasError) with explicit, type-safe states.

Branded Types for Domain Safety

TypeScript uses structural typing. Two types with the same shape are interchangeable, even if they represent different domain concepts. A UserId and an OrderId are both string, so nothing stops you from passing one where the other is expected.

Branded types add a phantom property that exists only at the type level to break structural compatibility:

typescript — Branded types
// Declare branded types
type UserId = string & { readonly __brand: unique symbol };
type OrderId = string & { readonly __brand: unique symbol };

// Constructor functions (the only way to create branded values)
function createUserId(id: string): UserId {
  // Add validation here if needed
  return id as UserId;
}

function createOrderId(id: string): OrderId {
  return id as OrderId;
}

// Type-safe functions
function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }

// Usage
const userId = createUserId('usr_123');
const orderId = createOrderId('ord_456');

getUser(userId);    // Works
getUser(orderId);   // Error: OrderId is not assignable to UserId
getUser('usr_789'); // Error: string is not assignable to UserId

Branded Types for Validated Data

typescript — Validation branding
type Email = string & { readonly __brand: unique symbol };
type NonEmptyString = string & { readonly __brand: unique symbol };

function validateEmail(input: string): Email | null {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(input) ? (input as Email) : null;
}

function sendEmail(to: Email, subject: NonEmptyString): void {
  // Both parameters are guaranteed to be validated
}

// Calling code MUST validate first
const email = validateEmail(userInput);
if (email) {
  sendEmail(email, subject); // Only reachable with valid email
}

This pattern moves validation to the boundary of your system and makes it impossible to use unvalidated data in the core logic.

The satisfies Operator

Introduced in TypeScript 4.9, satisfies solves a longstanding tension: you want to validate that a value conforms to a type, but you also want TypeScript to infer the narrowest possible type for autocompletion.

The Problem

typescript — Type annotation loses specificity
type RouteConfig = Record<string, { path: string; auth: boolean }>;

// With type annotation: keys are widened to 'string'
const routes: RouteConfig = {
  home:    { path: '/',         auth: false },
  profile: { path: '/profile',  auth: true },
  admin:   { path: '/admin',    auth: true },
};

routes.home;     // Works, but TypeScript does not know 'home' is a valid key
routes.banana;   // No error! Keys are just 'string'

The Solution

typescript — satisfies preserves specificity
const routes = {
  home:    { path: '/',         auth: false },
  profile: { path: '/profile',  auth: true },
  admin:   { path: '/admin',    auth: true },
} satisfies RouteConfig;

routes.home;     // Works, with full autocomplete
routes.banana;   // Error: Property 'banana' does not exist
routes.home.path // Type is '/' (literal), not string

With satisfies, TypeScript validates the value against RouteConfig (catching typos or wrong property types) while preserving the literal types of every key and value. You get both validation and precision.

satisfies with as const

typescript — Maximum specificity
const colors = {
  primary: '#00d4ff',
  accent:  '#a855f7',
  error:   '#ef4444',
} as const satisfies Record<string, `#${string}`>;

// colors.primary is type '#00d4ff' (literal), not string
// Every value is validated as a hex color string

Test TypeScript Patterns in Your Browser

The QTool TypeScript Playground lets you write, compile, and test TypeScript code instantly. No setup, no installation, no signup.

Open TypeScript Playground JSON to TypeScript

Const Assertions and Literal Types

By default, TypeScript widens literal values. const x = 'hello' infers the type 'hello', but let x = 'hello' infers string. For objects and arrays, even const declarations widen the property values because the properties themselves are mutable.

typescript — as const
// Without as const: properties are mutable, types are wide
const config = {
  api: 'https://api.example.com',
  timeout: 5000,
  retries: 3,
};
// config.api is type string
// config.timeout is type number

// With as const: properties are readonly, types are literal
const config = {
  api: 'https://api.example.com',
  timeout: 5000,
  retries: 3,
} as const;
// config.api is type 'https://api.example.com'
// config.timeout is type 5000

as const for Tuples

typescript — Tuple inference
// Without as const: inferred as (string | number)[]
const pair = ['name', 42];

// With as const: inferred as readonly ['name', 42]
const pair = ['name', 42] as const;
// pair[0] is 'name', pair[1] is 42

// Useful for function returns
function useToggle(initial: boolean) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle] as const; // readonly [boolean, () => void]
}

Enum Replacement with as const

typescript — Object enum pattern
// Instead of enum (which has runtime overhead and bundle bloat)
const Status = {
  Active: 'active',
  Inactive: 'inactive',
  Pending: 'pending',
} as const;

type Status = typeof Status[keyof typeof Status];
// type Status = 'active' | 'inactive' | 'pending'

function setStatus(status: Status) { /* ... */ }
setStatus(Status.Active);  // Works
setStatus('active');        // Also works
setStatus('deleted');       // Error

This pattern gives you named constants (like an enum), union type safety, and zero runtime overhead because the object compiles to plain JavaScript without the synthetic code that TypeScript enums generate.

Template Literal Types

Template literal types let you create string types using the same template syntax as JavaScript template literals. They are invaluable for APIs that rely on string patterns.

typescript — Template literal types
// Basic: string pattern types
type CssUnit = `${number}${'px' | 'rem' | 'em' | '%' | 'vh' | 'vw'}`;

function setWidth(width: CssUnit) { /* ... */ }
setWidth('100px');   // OK
setWidth('2.5rem');  // OK
setWidth('100');     // Error: not a valid CssUnit

// Event handler pattern
type EventName = 'click' | 'focus' | 'blur' | 'change';
type EventHandler = `on${Capitalize<EventName>}`;
// type EventHandler = 'onClick' | 'onFocus' | 'onBlur' | 'onChange'

Dynamic Key Types

typescript — Route builder
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';
type ApiEndpoint = `/api/${ApiVersion}/${Resource}`;
// type ApiEndpoint = '/api/v1/users' | '/api/v1/orders' | '/api/v1/products'
//                  | '/api/v2/users' | '/api/v2/orders' | '/api/v2/products'

function fetchResource(endpoint: ApiEndpoint): Promise<Response> {
  return fetch(endpoint);
}

fetchResource('/api/v1/users');    // OK
fetchResource('/api/v3/users');    // Error: v3 is not valid

String Manipulation Types

TypeScript provides four intrinsic string manipulation types that work at the type level:

typescript — String type utilities
type Upper = Uppercase<'hello'>;       // 'HELLO'
type Lower = Lowercase<'HELLO'>;       // 'hello'
type Cap = Capitalize<'hello'>;        // 'Hello'
type Uncap = Uncapitalize<'Hello'>;    // 'hello'

// Practical: convert snake_case keys to camelCase type
type CamelCase<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<CamelCase<Tail>>}`
    : S;

type Result = CamelCase<'user_first_name'>; // 'userFirstName'

If you are building an ESLint configuration to enforce consistent naming conventions alongside these type-level patterns, the ESLint Config Generator can produce a TypeScript-aware config with naming convention rules pre-configured.

TypeScript Developer Tools


Frequently Asked Questions

Yes. Strict mode enables a set of compiler flags including strictNullChecks, noImplicitAny, strictFunctionTypes, and strictPropertyInitialization. These flags catch entire categories of bugs at compile time that would otherwise surface as runtime errors. Every major TypeScript project and framework (Angular, Next.js, SvelteKit) ships with strict mode enabled by default. Starting a new project without strict mode means you will eventually migrate to it anyway, but with a larger codebase and more errors to fix. Enable it from day one in tsconfig.json with "strict": true.

Interfaces use declaration merging (multiple declarations with the same name are automatically combined) and can only describe object shapes. Types are more flexible: they support unions, intersections, mapped types, conditional types, and primitives. Use interfaces when defining object contracts that consumers might need to extend (like library APIs or plugin systems). Use types for everything else: union types, utility type combinations, function signatures, and complex mapped or conditional types. In practice, most teams pick one as the default and use the other only when its unique feature is needed. Performance-wise, the TypeScript compiler resolves interfaces slightly faster in large projects due to caching, but the difference is negligible for most codebases.

Use generics when a function, class, or type needs to work with multiple types while preserving the relationship between input and output types. The classic example is a function that takes a value of some type and returns a value of that same type, like an identity function or an array wrapper. Without generics, you would use any (losing type safety) or write separate overloads for each type (losing maintainability). Generics are essential for utility functions (pick, omit, map, filter), data structures (Stack<T>, Queue<T>), API response wrappers (ApiResponse<T>), and Higher-Order Components or hooks that wrap other typed logic. Avoid generics when a concrete type is sufficient or when the generic parameter is only used once, which usually means it adds complexity without value.

The satisfies operator, introduced in TypeScript 4.9, lets you validate that a value matches a type without widening the inferred type. With a normal type annotation (const x: Type = value), TypeScript uses the declared type and loses any narrower information from the value. With satisfies (const x = value satisfies Type), TypeScript validates that the value conforms to Type but infers the most specific type possible. This is useful for configuration objects where you want both type checking against a schema and autocompletion for the specific literal values. For example, const routes = { home: '/home', about: '/about' } satisfies Record<string, string> ensures every value is a string while preserving the literal key and value types for autocompletion.

Branded types (also called nominal types or opaque types) add a phantom property to a type to prevent accidental interchangeability between structurally identical types. For example, UserId and OrderId might both be strings at runtime, but with branding you can make the compiler reject passing a UserId where an OrderId is expected. You create a branded type by intersecting the base type with a unique symbol property: type UserId = string & { readonly __brand: unique symbol }. Then you create values through a constructor function that casts the raw value: function createUserId(id: string): UserId { return id as UserId; }. This pattern is widely used for IDs, currency amounts, validated email addresses, and any domain value where mixing up structurally identical types causes bugs.

The most commonly used built-in utility types are Partial<T> (makes all properties optional), Required<T> (makes all properties required), Pick<T, Keys> (selects specific properties), Omit<T, Keys> (removes specific properties), Record<Keys, Type> (creates an object type with specified keys and value type), Readonly<T> (makes all properties readonly), ReturnType<T> (extracts a function's return type), and Parameters<T> (extracts a function's parameter types as a tuple). For more advanced use cases, NonNullable<T> removes null and undefined, Awaited<T> unwraps Promise types, Extract<T, U> and Exclude<T, U> filter union members, and Uppercase/Lowercase/Capitalize/Uncapitalize transform string literal types. These utility types compose well together, for example Partial<Pick<User, 'name' | 'email'>> creates a type with optional name and email fields from the User type.

NT

Christian Bucher

We build free developer tools for TypeScript, JavaScript, JSON, and more. 269 tool pages, all browser-based, no signup required.

269 Developer Tools, One Place

Browse 269 indexed tool pages with no QTool account required, and inspect the source on GitHub.

Open Source — Free Forever Try Free Tools

Related Tools

CSS Box Shadow Generator · Emoji Picker & Search · Free JSON to YAML Converter

Related Tools

Free JSON to YAML Converter · YAML to JSON Converter · Free API Mock Server

Related Articles

Built by Miguel

Need a custom tool or website?

From . Delivered in 24-48h. You own the code.

View Services →