I want to be upfront: TypeScript's advanced type system genuinely confused me for a long time. I used it for about six months before I moved beyond basic type annotations and interface definitions. The things that finally made it click — structural typing, how generics actually work, conditional types — were not covered in any tutorial I found when I was starting out. They were explained to me by a colleague over a lunch break, and I went home and rewrote about thirty types that same evening.
This article is written for the version of me that was six months in, typing as any whenever TypeScript complained about something I did not understand, and wondering what all the fuss was about.
Structural Typing: The Rule That Governs Everything
TypeScript uses a structural type system. Two types are compatible if their shapes are compatible — not because of their names. This is the foundational principle, and understanding it changes how you read type errors.
interface HasName {
name: string;
}
// This works — { name, age } is structurally compatible with HasName
// because it has everything HasName requires (plus more)
function greet(entity: HasName) {
return `Hello, ${entity.name}`;
}
const user = { name: "Norehan", age: 28 };
greet(user); // ✓ — extra properties are fine here
// This also works — a class with the right shape satisfies the interface
// without needing to explicitly declare "implements HasName"
class Product {
name: string;
constructor(name: string) { this.name = name; }
}
greet(new Product("Widget")); // ✓
The implication: if TypeScript tells you two types are incompatible, it means their shapes do not match — one is missing a property or a property has the wrong type. Reading the error message in terms of "what shape does each side have?" is almost always the fastest path to understanding what is wrong.
Union Types: Modelling Real States
Before I understood union types properly, I was using optional properties everywhere — data?: User; error?: string; — to represent different states. The problem is that this allows invalid combinations: { data: user, error: "something went wrong" } is structurally valid. A discriminated union makes invalid states unrepresentable:
// What I used to write — allows impossible states
interface ApiState {
loading: boolean;
data?: User;
error?: string;
}
// What I write now — each state is exact and exclusive
type ApiState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; message: string };
// TypeScript narrows automatically in switch statements
function render(state: ApiState) {
switch (state.status) {
case "idle": return null;
case "loading": return <Spinner />;
case "success": return <UserCard user={state.data} />; // data is User here
case "error": return <ErrorMsg text={state.message} />;
// Add a new status variant and this switch gives a compile error — exhaustiveness
}
}
The discriminated union pattern is the one change to my TypeScript style that has eliminated the most runtime errors. States that cannot exist cannot cause bugs.
Generics: When They Stopped Feeling Like Magic
Generics confused me until I stopped thinking of them as "type variables" and started thinking of them as "placeholders that get filled in at call time." The type parameter T is not a variable that holds a value — it is a slot in the function's type signature that gets filled with a specific type when the function is called.
// This function works with any type of array — T is filled in at each call site
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const name = first(["Alice", "Bob"]); // TypeScript infers T = string
const id = first([1, 2, 3]); // TypeScript infers T = number
// name is string | undefined, id is number | undefined — not any
// Constraints let you require a minimum shape
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Norehan", role: "engineer" };
const role = getProperty(user, "role"); // ✓ string
const bad = getProperty(user, "missing"); // ✗ compile error — "missing" not in user
The extends keyof T constraint on K is the piece I found most powerful when I finally understood it. It says: K must be a key of T, and the return type T[K] resolves to exactly the type of that property. No runtime errors. No type assertions. TypeScript just knows.
Conditional Types: Type-Level Computation
Conditional types are the first feature that made me think of TypeScript's type system as genuinely computational — not just annotation, but actual logic at the type level.
// A type that "unwraps" a Promise — if T is a Promise, give me what it resolves to
type Awaited<T> = T extends Promise<infer R> ? R : T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<number>; // number — not a promise, returns as-is
// Practical: extract the element type of an array
type ElementOf<T> = T extends (infer Item)[] ? Item : never;
type C = ElementOf<User[]>; // User
type D = ElementOf<string>; // never — not an array
// Non-nullable: strip null and undefined from a type
type NonNullable<T> = T extends null | undefined ? never : T;
type E = NonNullable<string | null | undefined>; // string
The infer keyword in conditional types is what makes them powerful: it lets you extract a part of a matched type and give it a name to use in the "then" branch. Once I understood infer, the TypeScript standard library's utility types stopped looking like black magic and started looking like straightforward type-level functions.
Mapped Types: Transforming Shapes Systematically
Mapped types let you create a new type by iterating over the properties of an existing type. All of TypeScript's built-in utility types are implemented this way:
// How Partial<T> is implemented — every property becomes optional
type Partial<T> = { [K in keyof T]?: T[K] };
// How Readonly<T> is implemented — every property becomes readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// A useful custom: make only specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Example: a User where only `email` is optional (for update operations)
type UserUpdate = PartialBy<User, "email" | "role">;
The Practical Advice I Wish I Had Had Earlier
- Enable
"strict": truefrom the start. Turning it on in an existing codebase is painful. Starting strict means the type system is actually helping you rather than giving you a false sense of safety. - Prefer
unknownoveranyfor values of uncertain type.unknownforces you to narrow before use.anyturns off type checking entirely — it is an escape hatch that accumulates technical debt. - Use discriminated unions for state. This one change eliminated more runtime errors in my code than any other TypeScript practice.
- Use
satisfieswhen you want validation without widening.const config = { ... } satisfies Configvalidates shape while preserving the literal types inferred from the value. - Treat
as Ttype assertions as code smells. Each one is a promise to the compiler that you might be lying about. Every assertion you write is a place where TypeScript stops protecting you.
TypeScript's advanced type system takes time to internalise, and I think that is fine. The basic types — interfaces, union types, generics — cover the majority of real-world cases. The advanced features are there when your problem genuinely requires them. The goal is not to use every feature; it is to know they exist so you reach for them when they are actually the right tool.
Further Reading
- TypeScript Handbook: Types from Types — the official coverage of generic types, conditional types, and mapped types
- Type-Level TypeScript — a deep interactive course on the type system as a programming language
- Total TypeScript: Type Transformations Workshop — practical exercises by Matt Pocock, the best hands-on practice I have found