TypeScript Tips for Cleaner Code

TypeScriptJavaScriptBest Practices
January 12, 2026
By Michał Hachuła
5 minutes read

Intro

TypeScript has become the standard for building robust JavaScript applications. After years of working with it, I've collected some tips that consistently help me write cleaner, more maintainable code. Let's dive in.


1. Use satisfies for Type Checking Without Widening

The satisfies operator (introduced in TypeScript 4.9) lets you validate that an expression matches a type while preserving the narrowest possible type inference.

type Colors = Record<string, [number, number, number] | string>;

// Without satisfies - loses specific key information
const colorsWide: Colors = {
  red: [255, 0, 0],
  green: "#00ff00",
};
// colorsWide.red is [number, number, number] | string

// With satisfies - keeps literal types
const colors = {
  red: [255, 0, 0],
  green: "#00ff00",
} satisfies Colors;
// colors.red is [number, number, number]
// colors.green is string

This is particularly useful for configuration objects where you want both type safety and precise autocomplete.


2. Discriminated Unions for State Management

Instead of using optional properties or separate boolean flags, model your state with discriminated unions:

// Avoid this
type RequestState = {
  isLoading: boolean;
  error?: Error;
  data?: User;
};

// Prefer this
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: User };

function handleState(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Ready to fetch";
    case "loading":
      return "Loading...";
    case "error":
      return `Error: ${state.error.message}`; // error is available
    case "success":
      return `Hello, ${state.data.name}`; // data is available
  }
}

TypeScript will narrow the type in each branch, and you get exhaustiveness checking for free.


3. const Assertions for Literal Types

Use as const to create readonly literal types from objects and arrays:

// Without as const
const routes = {
  home: "/",
  blog: "/blog",
  about: "/about",
};
// Type: { home: string; blog: string; about: string }

// With as const
const routes = {
  home: "/",
  blog: "/blog",
  about: "/about",
} as const;
// Type: { readonly home: "/"; readonly blog: "/blog"; readonly about: "/about" }

// Now you can derive types from it
type Route = (typeof routes)[keyof typeof routes];
// Type: "/" | "/blog" | "/about"

4. Template Literal Types for String Patterns

TypeScript's template literal types let you create precise string types:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiVersion = "v1" | "v2";

type Endpoint = `/${ApiVersion}/${string}`;
// Matches "/v1/users", "/v2/posts", etc.

type EventName = `on${Capitalize<string>}`;
// Matches "onClick", "onHover", "onSubmit", etc.

// Combine with mapped types
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type Person = { name: string; age: number };
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }

5. infer for Extracting Types

The infer keyword lets you extract types within conditional types:

// Extract return type of a function
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;

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

// Extract promise value
type Awaited<T> = T extends Promise<infer V> ? V : T;

// Practical example: extract props type from a component
type PropsOf<T> = T extends React.ComponentType<infer P> ? P : never;

6. Branded Types for Extra Safety

Create distinct types that are structurally identical but nominally different:

type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = "user_123" as UserId;
const postId = "post_456" as PostId;

getUser(userId); // OK
getUser(postId); // Error: PostId is not assignable to UserId

This prevents accidentally passing the wrong ID type to functions.


7. NoInfer for Controlling Type Inference

TypeScript 5.4 introduced NoInfer to prevent certain positions from contributing to type inference:

function createStore<T>(initial: T, defaultValue: NoInfer<T>) {
  return { value: initial, default: defaultValue };
}

// Without NoInfer, this would infer T as string | number
// With NoInfer, T is inferred only from the first argument
createStore("hello", 42); // Error: number is not assignable to string

Closing Remarks

These patterns have helped me catch bugs at compile time rather than runtime, and they make code more self-documenting. TypeScript's type system is powerful enough to encode many invariants directly in your types.

Start with the basics and gradually adopt more advanced patterns as you become comfortable. The goal is always clarity and correctness, not type gymnastics for its own sake.

What TypeScript patterns do you find most useful? I'd love to hear about them.