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.
satisfies for Type Checking Without WideningThe 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.
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.
const Assertions for Literal TypesUse 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"
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 }
infer for Extracting TypesThe 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;
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.
NoInfer for Controlling Type InferenceTypeScript 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
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.