Mastering TypeScript: Tips and Tricks


TypeScript pays off when it stops bad states at compile time, not when it makes types harder to read than the runtime code.
These are patterns I use weekly — each one fixed a real bug or saved a refactor. I skip the exotic stuff unless the domain demands it.
What: Narrow strings to a fixed set.
type UserRole = "admin" | "editor" | "viewer";
interface User {
id: number;
name: string;
role: UserRole;
}
function canEdit(user: User) {
return user.role === "admin" || user.role === "editor";
}
Why: Typos in role strings become compile errors, not silent wrong branches.
When not to: Fifty roles maintained in DB — use string at the boundary, validate with zod at runtime.
What: A shared kind or type field lets TypeScript narrow safely.
type Result =
| { ok: true; data: string }
| { ok: false; error: string };
function handle(result: Result) {
if (result.ok) {
console.log(result.data);
} else {
console.error(result.error);
}
}
Why: No optional data? and error? on the same object — impossible states stay unrepresentable.
Red flag: Giant unions without a discriminator — if ('data' in x) works but gets messy.
satisfies — check shape, keep inferenceWhat: Validate an object matches a type without widening literals.
type Route = { path: string; label: string };
const routes = [
{ path: "/", label: "Home" },
{ path: "/blog", label: "Blog" },
] satisfies Route[];
// routes[0].path is still "/" not string — autocomplete kept
Why: Config objects and theme tokens stay typed and literal.
as const for tuples and mapsconst STATUS = ["pending", "done", "failed"] as const;
type Status = (typeof STATUS)[number];
Why: One source of truth for runtime array and compile-time union.
interface User {
id: number;
name: string;
email: string;
}
type UserUpdate = Partial<Pick<User, "name" | "email">>;
Why: PATCH endpoints and form state do not need a hand-rolled duplicate interface.
When not to: Over-Pick/Omit until the type is unreadable — sometimes a small dedicated type is clearer.
What: strict: true in tsconfig — especially strictNullChecks.
function getName(user: User | undefined) {
if (!user) return "Guest";
return user.name;
}
Why: Most production bugs I have seen are null/undefined assumptions.
Red flag: ! non-null assertion to silence the compiler. Fix the flow instead.
in and type guardsWhen unions get wider, I use a kind field or in checks instead of casting.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape) {
if (shape.kind === "circle") return Math.PI * shape.radius ** 2;
return shape.width * shape.height;
}
Same idea as discriminated unions — keeps math functions honest at compile time.
function foo<T extends U extends V> when a simple interface worked.unknown from JSON — as MyType without parsing is hope, not typing.Turn on strict if you have not. Pick one pattern from this list and apply it to the messiest module in your project — usually API responses or form state.
TypeScript is a linter for your assumptions. The best tricks are the ones that delete branches, not add them.