4 min read
TypeScript Patterns That Actually Matter
Most TypeScript advice falls into one of two categories: so basic it's in the official docs already, or so advanced it solves a problem you've never had. This is neither. These are five patterns I reach for constantly, each one fixing a real mistake I used to make.
Stop using any. Use unknown and narrow it
any is a white flag. You're telling TypeScript to stop checking a value entirely. The problem is that any spreads silently: once something is typed as any, anything you derive from it is also any, and you've quietly turned off type safety for that whole path through your code.
unknown is the correct alternative. It says "I don't know what this is yet, but I will before I use it." TypeScript won't let you operate on an unknown value until you've narrowed it to something specific.
The most common place I see any misused is in catch blocks:
// Bad — e is implicitly any
try {
await fetchUser(id);
} catch (e) {
console.error(e.message); // no error, but no safety either
}
// Good — e is unknown, you have to check first
try {
await fetchUser(id);
} catch (e) {
if (e instanceof Error) {
console.error(e.message);
}
}
The narrowing check takes five seconds. What you get back is a guarantee that e.message actually exists. That's a good trade.
Use satisfies instead of type assertions
as SomeType is the other white flag. You're asserting to the compiler that you know better. Sometimes that's legitimate. More often, it's a way of silencing a type error you haven't fully thought through.
Beyond the safety risk, as throws away inference. When you assert a value as SomeType, TypeScript widens the type to match whatever SomeType says, losing any narrower information it had already worked out.
satisfies does something different. It validates that a value matches a type without changing what TypeScript infers about it.
type Config = {
model: string;
temperature: number;
};
// With 'as': model is typed as string
const config = {
model: "gpt-4o",
temperature: 0.7,
} as Config;
// With 'satisfies': model is typed as "gpt-4o" (the literal)
const config = {
model: "gpt-4o",
temperature: 0.7,
} satisfies Config;
In the satisfies version, config.model is still "gpt-4o", not just string. You get the validation and you keep the precision. I've replaced most of my as usages with satisfies and caught several bugs in the process.
Model state with discriminated unions
Optional fields are a code smell. When you see a type with several optional properties, it usually means the type is trying to describe multiple different states at once.
// This is hiding complexity
type Post = {
status: "draft" | "scheduled" | "posted";
scheduledAt?: Date;
postedAt?: Date;
postUrl?: string;
};
A scheduled post needs scheduledAt. A posted post needs postedAt and postUrl. A draft needs neither. With the type above, none of that is enforced. You're one missing check away from a runtime error.
Discriminated unions fix this properly:
type DraftPost = { status: "draft" };
type ScheduledPost = {
status: "scheduled";
scheduledAt: Date;
};
type PostedPost = {
status: "posted";
postedAt: Date;
postUrl: string;
};
type Post = DraftPost | ScheduledPost | PostedPost;
Now TypeScript knows exactly what's available in each branch. Switch on status and you get full narrowing: inside the "scheduled" case, scheduledAt is guaranteed to be there. The compiler catches the cases you miss. Optional fields can't do that.
String literal unions over enums
TypeScript enums have a compile cost that often goes unnoticed. A numeric enum compiles to a bidirectional runtime object. A string enum compiles to a runtime object too. Neither is tree-shakeable in the usual sense. You're shipping code for something that could be a pure type.
String literal unions have zero runtime cost:
// Enum — compiles to runtime JS object
enum Direction {
Up = "UP",
Down = "DOWN",
}
// String literal union — zero runtime output
type Direction = "UP" | "DOWN";
For the cases where you want to iterate over the values, the as const pattern handles it cleanly:
const DIRECTIONS = ["UP", "DOWN", "LEFT", "RIGHT"] as const;
type Direction = (typeof DIRECTIONS)[number];
DIRECTIONS is a readonly tuple of string literals. Direction is the union derived from it. You have one source of truth, and if you add a value to the array, the type updates automatically. This pattern also works naturally with Zod, Prisma, and any other library that expects plain string values rather than enum members.
Derive types from Zod schemas
If you're using Zod for runtime validation (and you should be, at any API boundary), you almost certainly have a duplicated type problem. The schema describes the shape of your data. Then somewhere nearby there's a TypeScript interface describing the same shape again.
// Schema
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
});
// Type (don't do this)
type User = {
id: string;
email: string;
role: "admin" | "user";
};
These two will drift apart. Someone adds a field to the schema and forgets the type. Someone changes a field in the type and doesn't update the schema. The bugs are subtle and show up at runtime.
z.infer solves this in one line:
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
});
type User = z.infer<typeof UserSchema>;
User is now derived directly from UserSchema. They can't diverge. Zod handles runtime validation. TypeScript handles compile-time correctness. Both from a single definition.
None of these patterns require a deep understanding of TypeScript's type system. They're practical defaults. Replace any with unknown, reach for satisfies before as, model your states properly, skip enums, and keep your Zod schemas as the source of truth for your types. Apply them consistently and your TypeScript stops being a layer of annotations on top of JavaScript and starts being a tool that catches real problems.