ToolBox Hub

TypeScript Best Practices in 2026: Write Better, Safer Code

TypeScript Best Practices in 2026: Write Better, Safer Code

Master TypeScript with these essential best practices for 2026. From advanced type patterns and error handling to performance optimization and project configuration, this comprehensive guide covers everything.

March 14, 202614 min read

Introduction: TypeScript is the Standard

TypeScript has firmly established itself as the standard for professional JavaScript development. In 2026, over 80% of new JavaScript projects start with TypeScript, and for good reason: it catches bugs at compile time, improves code documentation through types, enables better IDE support, and makes large codebases maintainable.

But writing TypeScript is not the same as writing good TypeScript. Many developers use TypeScript as "JavaScript with type annotations" without taking advantage of its full power. This guide covers the best practices that will help you write cleaner, safer, and more maintainable TypeScript code in 2026.

Whether you are building a Next.js application, an Express API, a React Native mobile app, or a Node.js CLI tool, these practices apply across the TypeScript ecosystem.

Part 1: Type System Fundamentals Done Right

Use unknown Instead of any

The any type is TypeScript's escape hatch -- it disables all type checking. Using it defeats the purpose of TypeScript.

// BAD: any disables type checking
function processData(data: any) {
  return data.name.toUpperCase(); // No error, but could crash at runtime
}

// GOOD: unknown forces you to check types
function processData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'name' in data) {
    const record = data as { name: string };
    return record.name.toUpperCase(); // Safe!
  }
  throw new Error('Invalid data format');
}

When any is acceptable:

  • Migrating JavaScript to TypeScript (temporarily)
  • Third-party libraries with no type definitions
  • Generic utility functions where types genuinely cannot be known

Even in these cases, prefer unknown and add type guards.

Prefer Type Inference Where Obvious

TypeScript's type inference is powerful. Do not annotate types that the compiler can infer.

// UNNECESSARY: TypeScript already infers these
const name: string = "John";
const count: number = 42;
const items: string[] = ["a", "b", "c"];

// BETTER: Let TypeScript infer
const name = "John";      // inferred as string
const count = 42;          // inferred as number
const items = ["a", "b"]; // inferred as string[]

// DO annotate: function parameters and return types
function calculateTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// DO annotate: when inference would be too wide
const status: "active" | "inactive" = "active"; // not just string

Use const Assertions for Literal Types

// Without const assertion: type is { name: string, role: string }
const config = { name: "admin", role: "superuser" };

// With const assertion: type is { readonly name: "admin", readonly role: "superuser" }
const config = { name: "admin", role: "superuser" } as const;

// Great for arrays that should be tuples
const colors = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]

// Perfect for enum-like objects
const HttpStatus = {
  OK: 200,
  NOT_FOUND: 404,
  SERVER_ERROR: 500,
} as const;

type StatusCode = typeof HttpStatus[keyof typeof HttpStatus]; // 200 | 404 | 500

Discriminated Unions Over Optional Properties

When an object can be in different states, use discriminated unions instead of optional properties.

// BAD: Everything is optional, hard to know what is valid
interface ApiResponse {
  status: string;
  data?: unknown;
  error?: string;
  retryAfter?: number;
}

// GOOD: Each state is clearly defined
type ApiResponse =
  | { status: "success"; data: unknown }
  | { status: "error"; error: string; retryAfter?: number }
  | { status: "loading" };

function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case "success":
      console.log(response.data); // TypeScript knows data exists
      break;
    case "error":
      console.error(response.error); // TypeScript knows error exists
      break;
    case "loading":
      console.log("Loading...");
      break;
  }
}

Use satisfies for Type Validation Without Widening

The satisfies operator (introduced in TypeScript 4.9) validates a value against a type without losing its specific type information.

type Route = {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
};

// Using type annotation: loses specific string information
const route: Route = { path: "/api/users", method: "GET" };
// route.path is just `string`

// Using satisfies: validates AND preserves literal types
const route = {
  path: "/api/users",
  method: "GET",
} satisfies Route;
// route.path is "/api/users" (literal type preserved)
// route.method is "GET" (literal type preserved)

// Great for configuration objects
type Config = Record<string, { url: string; timeout: number }>;

const apiConfig = {
  users: { url: "/api/users", timeout: 5000 },
  products: { url: "/api/products", timeout: 3000 },
} satisfies Config;

// apiConfig.users is known to exist (autocomplete works!)

Part 2: Advanced Type Patterns

Template Literal Types

Template literal types let you create string types from combinations.

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiVersion = "v1" | "v2";
type Endpoint = `/${ApiVersion}/${string}`;

// Create event handler types automatically
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// Result: "onClick" | "onFocus" | "onBlur"

// CSS unit types
type CSSUnit = "px" | "rem" | "em" | "%";
type CSSValue = `${number}${CSSUnit}`;

function setWidth(value: CSSValue) { /* ... */ }
setWidth("100px");  // OK
setWidth("2rem");   // OK
setWidth("100");    // Error: not a valid CSSValue

Utility Types You Should Know

TypeScript provides built-in utility types that save you from writing boilerplate.

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

// Partial: Make all properties optional
type UpdateUser = Partial<User>;

// Pick: Select specific properties
type UserPublic = Pick<User, "id" | "name" | "email">;

// Omit: Remove specific properties
type UserWithoutPassword = Omit<User, "password">;

// Required: Make all properties required
type CompleteUser = Required<Partial<User>>;

// Record: Create object type with specific keys
type UserRoles = Record<string, "admin" | "user" | "moderator">;

// Extract / Exclude: Filter union types
type StringOrNumber = string | number | boolean;
type OnlyStrings = Extract<StringOrNumber, string>; // string
type NoStrings = Exclude<StringOrNumber, string>;   // number | boolean

// ReturnType: Get the return type of a function
function createUser() { return { id: 1, name: "John" }; }
type NewUser = ReturnType<typeof createUser>; // { id: number; name: string }

// Parameters: Get parameter types of a function
type CreateUserParams = Parameters<typeof createUser>; // []

// Awaited: Unwrap Promise types
type UserData = Awaited<Promise<User>>; // User

Generic Constraints and Defaults

Write flexible but safe generic types.

// Constrained generic: T must have an id property
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

// Generic with default type
interface ApiOptions<T = unknown> {
  endpoint: string;
  method: HttpMethod;
  body?: T;
}

// Multiple constraints
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

// Conditional types
type IsArray<T> = T extends unknown[] ? true : false;
type Test1 = IsArray<string[]>;  // true
type Test2 = IsArray<string>;    // false

// Infer keyword in conditional types
type ElementType<T> = T extends (infer U)[] ? U : never;
type Item = ElementType<string[]>; // string

Branded Types for Type Safety

Use branded types to prevent mixing up values that have the same underlying type.

// Without branded types: easy to mix up IDs
function getUser(userId: string) { /* ... */ }
function getOrder(orderId: string) { /* ... */ }

const userId = "user_123";
const orderId = "order_456";
getUser(orderId); // No error! But this is a bug.

// With branded types: compiler catches the mistake
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function createUserId(id: string): UserId {
  return id as UserId;
}

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

function getUser(userId: UserId) { /* ... */ }
function getOrder(orderId: OrderId) { /* ... */ }

const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(orderId); // ERROR: Argument of type 'OrderId' is not assignable to 'UserId'
getUser(userId);  // OK!

Part 3: Error Handling Best Practices

Use Result Types Instead of Throwing

Throwing errors is unpredictable -- callers might forget to catch them. Result types make errors explicit.

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parseJSON(text: string): Result<unknown, string> {
  try {
    return { ok: true, value: JSON.parse(text) };
  } catch (e) {
    return { ok: false, error: `Invalid JSON: ${(e as Error).message}` };
  }
}

// Usage: caller MUST handle both cases
const result = parseJSON(userInput);
if (result.ok) {
  console.log(result.value); // TypeScript knows this is the success case
} else {
  console.error(result.error); // TypeScript knows this is the error case
}

You can test JSON parsing with our JSON Formatter tool to validate your inputs.

Type-Safe Error Handling with Custom Errors

class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number,
    public readonly context?: Record<string, unknown>
  ) {
    super(message);
    this.name = "AppError";
  }
}

class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(
      `${resource} with id ${id} not found`,
      "NOT_FOUND",
      404,
      { resource, id }
    );
  }
}

class ValidationError extends AppError {
  constructor(field: string, message: string) {
    super(
      `Validation failed for ${field}: ${message}`,
      "VALIDATION_ERROR",
      400,
      { field }
    );
  }
}

// Type-safe error handling
function handleError(error: unknown) {
  if (error instanceof NotFoundError) {
    // TypeScript knows error.context has resource and id
    return { status: 404, message: error.message };
  }
  if (error instanceof ValidationError) {
    return { status: 400, message: error.message };
  }
  if (error instanceof AppError) {
    return { status: error.statusCode, message: error.message };
  }
  return { status: 500, message: "Internal server error" };
}

Exhaustive Switch Statements

Use the never type to ensure switch statements handle all cases.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function calculateArea(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 you add a new shape but forget to handle it,
      // this line will cause a compile error!
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Part 4: Project Configuration

{
  "compilerOptions": {
    // Type Checking
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,

    // Modules
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "resolveJsonModule": true,

    // Emit
    "target": "ES2022",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",

    // Interop
    "esModuleInterop": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,

    // Other
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Key Compiler Options Explained

  • strict: true -- Enables all strict type checking options. Non-negotiable.
  • noUncheckedIndexedAccess -- Array access returns T | undefined, catching out-of-bounds errors.
  • exactOptionalPropertyTypes -- Distinguishes between undefined and "missing" properties.
  • verbatimModuleSyntax -- Enforces consistent import/export syntax.
  • isolatedModules -- Required for tools like esbuild, SWC, and Babel that compile files independently.

Part 5: Patterns for Modern TypeScript

Type-Safe API Clients

// Define your API schema
interface ApiSchema {
  "/users": {
    GET: { response: User[] };
    POST: { body: CreateUserInput; response: User };
  };
  "/users/:id": {
    GET: { response: User };
    PUT: { body: UpdateUserInput; response: User };
    DELETE: { response: void };
  };
}

// Type-safe fetch wrapper
async function api<
  Path extends keyof ApiSchema,
  Method extends keyof ApiSchema[Path]
>(
  path: Path,
  method: Method,
  options?: { body?: unknown }
): Promise<ApiSchema[Path][Method] extends { response: infer R } ? R : never> {
  const response = await fetch(path as string, {
    method: method as string,
    headers: { "Content-Type": "application/json" },
    body: options?.body ? JSON.stringify(options.body) : undefined,
  });
  return response.json();
}

// Usage: fully type-safe!
const users = await api("/users", "GET");        // User[]
const user = await api("/users/:id", "GET");     // User
const newUser = await api("/users", "POST", {    // User
  body: { name: "John", email: "john@example.com" }
});

Builder Pattern with Types

class QueryBuilder<T extends Record<string, unknown>> {
  private conditions: string[] = [];
  private params: unknown[] = [];

  where<K extends keyof T>(
    field: K,
    operator: "=" | "!=" | ">" | "<" | "LIKE",
    value: T[K]
  ): this {
    this.conditions.push(`${String(field)} ${operator} ?`);
    this.params.push(value);
    return this;
  }

  build(): { query: string; params: unknown[] } {
    const whereClause = this.conditions.length
      ? `WHERE ${this.conditions.join(" AND ")}`
      : "";
    return {
      query: `SELECT * FROM table ${whereClause}`,
      params: this.params,
    };
  }
}

// Usage
interface UserTable {
  id: number;
  name: string;
  age: number;
  email: string;
}

const query = new QueryBuilder<UserTable>()
  .where("age", ">", 18)        // TypeScript knows age must be number
  .where("name", "LIKE", "%John%") // TypeScript knows name must be string
  .build();

Zod for Runtime Validation

While TypeScript checks types at compile time, you still need runtime validation for external data (API requests, user input, file reads).

import { z } from "zod";

// Define schema
const UserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "user", "moderator"]),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

// Automatically infer the TypeScript type
type User = z.infer<typeof UserSchema>;
// Result: { name: string; email: string; age: number; role: "admin" | "user" | "moderator"; metadata?: Record<string, unknown> }

// Validate at runtime
function createUser(input: unknown): User {
  const result = UserSchema.safeParse(input);
  if (!result.success) {
    throw new ValidationError(result.error.message);
  }
  return result.data; // Fully typed!
}

Part 6: Performance Tips

Use const enum for Zero-Runtime Enums

// Regular enum: generates JavaScript code
enum Direction {
  Up = "UP",
  Down = "DOWN",
}
// Compiles to an object at runtime

// const enum: inlined at compile time (zero runtime overhead)
const enum Direction {
  Up = "UP",
  Down = "DOWN",
}
// References are replaced with literal values

Avoid Unnecessary Type Assertions

// BAD: Type assertion masks potential issues
const data = fetchData() as UserData;

// GOOD: Type guard validates the data
function isUserData(data: unknown): data is UserData {
  return (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "name" in data
  );
}

const data = fetchData();
if (isUserData(data)) {
  // data is safely typed as UserData here
}

Use Type Narrowing Effectively

// TypeScript narrows types automatically after checks
function processValue(value: string | number | null) {
  if (value === null) return "No value";

  // TypeScript knows value is string | number here
  if (typeof value === "string") {
    return value.toUpperCase(); // string methods available
  }

  return value.toFixed(2); // number methods available
}

// Use 'in' operator for object type narrowing
function handleEvent(event: MouseEvent | KeyboardEvent) {
  if ("key" in event) {
    console.log(event.key); // KeyboardEvent
  } else {
    console.log(event.clientX); // MouseEvent
  }
}

Part 7: Testing TypeScript Code

Type Testing with expect-type

import { expectTypeOf } from "expect-type";

// Verify function return types
expectTypeOf(createUser).returns.toEqualTypeOf<User>();

// Verify parameter types
expectTypeOf(createUser).parameter(0).toEqualTypeOf<CreateUserInput>();

// Verify that types are not assignable
expectTypeOf<string>().not.toBeAssignableFrom<number>();

Testing Tips

  1. Test types alongside runtime behavior -- Use tools like tsd or expect-type
  2. Use as unknown as Type for test fixtures -- Create partial mock data without satisfying every property
  3. Test error types -- Ensure functions throw the right error types
  4. Test generic functions with multiple type parameters -- Verify behavior for different type combinations

Conclusion

TypeScript is much more than "JavaScript with types." When used to its full potential, it is a powerful tool for building reliable, maintainable software. The practices in this guide represent the state of the art in 2026:

  1. Use the type system fully -- unknown over any, discriminated unions, branded types
  2. Let inference work -- Only annotate when necessary
  3. Make errors explicit -- Result types, exhaustive checks, custom error classes
  4. Configure strictly -- strict: true is the baseline
  5. Validate at boundaries -- Use Zod or similar for runtime validation
  6. Test your types -- Types are part of your codebase and should be tested

Start applying these practices today, and you will notice fewer bugs, better IDE support, and more confident refactoring.

Related Posts