Programming Concepts

TypeScript's Type System: What Clicked For Me After Six Months of Fighting It

For the first six months I used TypeScript mostly as JavaScript with type annotations. Then a colleague showed me conditional types and the mental model shifted entirely. Here is the understanding that changed how I write TypeScript.

Norehan Norrizan
··13 min read

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": true from 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 unknown over any for values of uncertain type. unknown forces you to narrow before use. any turns 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 satisfies when you want validation without widening. const config = { ... } satisfies Config validates shape while preserving the literal types inferred from the value.
  • Treat as T type 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

Filed under

TypeScriptJavaScripttypesgenericsfrontend