A curated collection of practical TypeScript patterns that improve safety, readability, maintainability, and developer experience.
Most of these are small individually. Together, they change how TypeScript code feels day to day.
- Prefer
unknownOverany - Let Type Inference Do the Work
- Prefer
satisfiesOveras - Derive Types From Values
- Make Invalid States Impossible to Represent
- Use Exhaustive Checks With
never - Use
as constfor Constants - Use Type Predicates
- Build New Types From Existing Types
- Validate External Data at Runtime
- Avoid
enumin Most Cases - Prefer Inferable Generics
- Enable Strict Compiler Options
- Learn Template Literal Types
- Type Safety β Runtime Safety
- Use Branded Types to Model Nominal Types
- Use
constType Parameters for Better Literal Inference
A lot of type safety starts here.
unknown forces you to prove what a value is before using it. any skips the type system entirely, allowing unsafe operations to spread through your code.
function parse(data: unknown) {
if (typeof data === "string") {
return data.toUpperCase();
}
}- Forces validation before use
- Preserves type safety
- Prevents unsafe type leakage
The best TypeScript code often relies on inference instead of repeating information the compiler already knows.
const name = "Ada";Instead of:
const name: string = "Ada";- Widens types
- Hurts inference
- Creates maintenance overhead
Inference tends to scale better than annotation.
Added in TS 4.9, and one worth adopting immediately.
const routes = {
home: "/",
about: "/about",
} satisfies Record<string, string>;Instead of:
const routes = {
home: "/",
about: "/about",
} as Record<string, string>;satisfies checks that a value matches a type while preserving its inferred type.
Use satisfies when validating object shapes. Reserve as for cases where you're expressing information the compiler genuinely can't infer.
One of the biggest TypeScript mindset shifts.
const roles = ["admin", "user", "guest"] as const;
type Role = (typeof roles)[number];This creates a single source of truth. If the runtime values change, the type updates automatically, eliminating duplication and preventing the two from drifting apart.
Good TypeScript models don't just describe data, they prevent impossible combinations from existing in the first place.
Discriminated unions are one of the most effective ways to model these constraints.
type State =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };These models scale much better than loose optional property blobs because invalid states simply can't be represented.
Future refactors become safer because the compiler ensures every valid state is handled.
Once you've modeled your states as a discriminated union, exhaustiveness checking ensures every case is handled.
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
throw state.error;
default: {
const exhaustive: never = state;
return exhaustive;
}
}
}Add a new state to the union, and the compiler immediately points out every place that needs updating.
Without as const:
const theme = {
mode: "dark",
};mode becomes string.
With as const:
const theme = {
mode: "dark",
} as const;Now it becomes 'dark'.
A small addition that meaningfully improves inference for configuration objects and constants.
Type predicates let a runtime check teach the compiler something.
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null && "id" in value;
}Then:
if (isUser(data)) {
data.id;
}This becomes especially useful around APIs and external input boundaries.
Think in transformations instead of duplication.
type UserPreview = Pick<User, "id" | "name">;PickOmitPartialRequired- Indexed access types
These utilities become much more valuable as applications grow.
TypeScript does not validate API responses.
This is one of the most misunderstood parts of TypeScript.
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});Every API response, form submission, environment variable, JSON file, and user input is an untrusted boundary.
TypeScript can't validate external data; you need runtime validation for that.
Usually simpler:
const roles = ["admin", "user"] as const;Than:
enum Role {
Admin,
User,
}In most application code, literal unions are easier to refactor, serialize, and work with than enums.
Enums still have valid use cases, but they're often unnecessary.
Great TypeScript APIs rarely require manual generic arguments. Design them so the type infers from what callers pass in.
Caller has to specify the type manually:
getData<User>("/api/user");T infers from the schema β nothing to annotate:
getData("/api/user", userSchema);If callers are constantly writing <SomeType>, that's usually a sign the API could do more of the work.
Many teams use TypeScript in "autocomplete mode."
Strict mode is where TypeScript really starts paying off.
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}strict is the baseline. The other two aren't covered by it, and they catch a real class of bugs that strict alone misses.
Underused, and worth learning.
type Route = `/api/${string}`;Excellent for:
- Routes
- Event names
- CSS utilities
- Design systems
- Query keys
Once you start using them, they show up everywhere.
This compiles:
const user = (await response.json()) as User;But it may still fail at runtime.
TypeScript improves correctness, but it isn't a runtime safety net.
- It does not validate external data
- It does not guarantee good architecture
- It does not eliminate runtime bugs
Use TypeScript to model your program well. Then validate anything that comes from the outside world.
TypeScript's type system is structural, not nominal. Two types with the same shape are interchangeable, even when they represent completely different concepts.
type UserId = string;
type OrderId = string;
function getUser(id: UserId) {
/* ... */
}
const orderId: OrderId = "order_123";
getUser(orderId); // No error, but this is almost certainly a bugBranding adds a compile-time-only tag that makes structurally identical types distinct:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function getUser(id: UserId) {
/* ... */
}
declare const orderId: OrderId;
getUser(orderId); // Error: OrderId is not assignable to UserIdThe brand only exists in the type system, there's no runtime cost, but it stops IDs, currencies, and other look-alike primitives from being swapped by mistake.
Added in TypeScript 5.0.
Without const, generic type parameters widen to their general type, forcing callers to add as const themselves to preserve literal types:
function first<T extends readonly unknown[]>(arr: T) {
return arr[0];
}
const result = first(["a", "b", "c"]); // stringWith the const modifier, the compiler infers the literal type directly from the argument:
function first<const T extends readonly unknown[]>(arr: T) {
return arr[0];
}
const result = first(["a", "b", "c"]); // "a"This is especially useful for APIs that should preserve exactly what the caller passed in, without asking the caller to remember as const.