# The only TypeScript you need to appear knowledgeable

Most TypeScript "knowledge" is a handful of ideas applied over and over. This is that handful — explained through the logic, not the syntax.

One rule to keep in your head the whole way down:

> **TypeScript has two universes: the value space (runtime) and the type space (compile time).** `const`, `let`, functions, objects live in the value space. `type`, `interface`, `keyof` live in the type space. `typeof` is the door from value space into type space. Almost everything below is a consequence of that.

* * *

## 1\. Interfaces (and the fact you can declare one twice)

Let's say you're modeling a user, and you declare it twice **in the same file**:

```ts
// user.ts
interface User {
  id: string;
  email: string;
}

interface User {
  plan: 'free' | 'pro';
}

// User is now { id: string; email: string; plan: 'free' | 'pro' }
```

Nobody overwrote anything. Two declarations of the same interface name **merge** their members. This is called *declaration merging*, and it is the one thing `interface` can do that `type` cannot.

### The rule that everyone gets wrong

Merging happens **within a declaration space**, not within a project. And **every file with a top-level** `import` **or** `export` **is its own module — its own declaration space.**

So this does *not* merge the interface:

```ts
// user.ts
export interface User { id: string; email: string }

// plugin.ts
export interface User { plan: 'free' | 'pro' }
```

These are two **unrelated types that happen to share a name**. `import { User } from './user'` still gives you `{ id, email }`. And if `plugin.ts` tries to import the original alongside its own declaration, you don't get a merge — you get `Import declaration conflicts with local declaration of 'User'`.

A file only merges into another file's types in two situations.

**1\. Both files are global.** A file with *no* top-level import/export isn't a module — it's a script, and its declarations land in the global space:

```ts
// globals.d.ts — no imports, no exports, deliberately
interface Window {
  __APP_STATE__: AppState; // ✅ merges into lib.dom's Window
}
```

```ts
// app.ts
window.__APP_STATE__; // ✅ compiles
```

Now add one line to the bottom of `globals.d.ts`:

```ts
// globals.d.ts - adds an export in the file
export {};
```

```ts
// app.ts
window.__APP_STATE__;
// ❌ Property '__APP_STATE__' does not exist on type 'Window & typeof globalThis'.
```

Nine characters with no semantic content, and the merge is gone. `globals.d.ts` is now a *module*, so its `Window` is a private local interface that happens to share a name with the real one.

Note **where the error lands**: in `app.ts` — the file you didn't touch. The file you actually broke reports nothing at all. This is why the bug is so expensive: you go hunting in the consumer, and the cause is a stray `export` (or an editor-inserted auto-import) in a declaration file you weren't even looking at.

The escape hatch is `declare global`, which pushes you back out into global scope from inside a module:

```ts
// globals.d.ts
import type { User } from './types'; // ← this alone makes the file a module

declare global {
  interface Window {
    __APP_STATE__: { user: User }; // ✅ merges again
  }
}

export {};
```

`declare global` is the global-scope sibling of `declare module 'specifier'`. Same job: name the declaration space you want to open.

**2\. Module augmentation.** The declaring file explicitly names the *module* scope it wants to reopen:

```ts
// express.d.ts — you never touch Express' source
import 'express'; // this file is a module...

declare module 'express-serve-static-core' {
  interface Request {
    currentUser?: User; // ...but this block lands in Express' declaration space
  }
}

// now req.currentUser type-checks in every handler in your app
```

`declare module 'specifier'` is the actual mechanism. Without it, a file cannot reach into another module's types — which is the right design. Otherwise any file in your repo could silently mutate any type in any other file.

This is how you extend `next-auth`'s `Session`, add a field to Express' `Request`, or teach `fastify` about your decorators. It's also why "just declare it again" never works when people try it in a normal `.ts` file.

The trade-off: an interface is *open*. Anyone who can name your module can add to it, and you'll only find out at the call site. That openness is the exact thing `type` gives up.

* * *

## 2\. Types (and the fact they can't be re-declared)

```ts
type User = {
  id: string;
  email: string;
};

type User = {
  plan: 'free' | 'pro';
};
// Error: Duplicate identifier 'User'.
```

A `type` is a **name for an expression**. Like `const` in the value space, the name is bound exactly once. You can't reopen it, which means when you read `type User = ...`, that is the whole truth about `User` in that file.

And because it's just a name for an expression, it can name things an interface can't. Here are the six you'll actually use — each one explained properly, because the names are useless until you see the logic.

* * *

### `|` — a union: "one of these"

Read `|` out loud as **"or"**.

```ts
type Status = 'draft' | 'published' | 'archived';
```

This says: a `Status` is the string `'draft'`, **or** the string `'published'`, **or** the string `'archived'`. Nothing else.

Not "a string." Those three strings. That's the point.

```ts
const a: Status = 'draft';     // ✅
const b: Status = 'published'; // ✅
const c: Status = 'deleted';   // ❌ Type '"deleted"' is not assignable to type 'Status'.
const d: Status = 'Draft';     // ❌ capital D — not one of the three
```

You can union anything, not just strings:

```ts
type Id = string | number;              // could be either
type MaybeUser = User | null;           // a user, or nothing
type Input = string | string[];         // one item or many
```

An interface cannot do this. There's no way to write "an interface that is one of three strings" — an interface describes an *object*, and `'draft'` is not an object. That is the single biggest reason `type` exists.

* * *

### `[a, b]` — a tuple: an array where each position has a meaning

A normal array says "many things, all the same type, any number of them":

```ts
type Scores = number[];
const s: Scores = [1, 2, 3, 4, 5]; // ✅ any length is fine
```

A **tuple** says "exactly this many things, in this order, and each slot has its own type":

```ts
type Point = [number, number];

const p: Point = [12, 40];     // ✅ x and y
const q: Point = [12];         // ❌ Source has 1 element but target requires 2.
const r: Point = [12, 40, 99]; // ❌ too many
const t: Point = [12, 'a'];    // ❌ second slot must be a number
```

The slots can have *different* types, which is where it gets useful:

```ts
type Result = [Error, null] | [null, User];
```

That reads: **either** an error and no user, **or** no error and a user. Never both, never neither. (That's a union of two tuples — you've now stacked two of these ideas.)

You already use tuples every day without noticing. This is what `useState` returns:

```ts
const [count, setCount] = useState(0);
// useState returns [number, (n: number) => void]
// slot 0 is the value, slot 1 is the setter — different types, fixed order
```

That's why destructuring `useState` gives you correctly-typed variables even though you invented the names `count` and `setCount` yourself. TypeScript isn't matching names, it's matching **positions**.

* * *

### `<T>` — a generic: a type with a hole in it

This is the one that scares people, and it shouldn't.

A generic is a **type that takes a type as an argument**. It's a function — but instead of taking a value and returning a value, it takes a type and returns a type.

Let's say you keep writing this:

```ts
type NullableUser = User | null;
type NullablePost = Post | null;
type NullableComment = Comment | null;
```

Same shape three times. The only thing that changes is the type on the left. So punch a hole in it and give the hole a name:

```ts
type Nullable<T> = T | null;
//            ↑        ↑
//     the hole    the hole, used
```

Now you *call* it, the same way you'd call a function — but with angle brackets instead of parens:

```ts
type NullableUser = Nullable<User>;    // → User | null
type NullablePost = Nullable<Post>;    // → Post | null
type MaybeName = Nullable<string>;     // → string | null
```

`T` is just a parameter name. It has no special meaning. You could write `Nullable<Whatever> = Whatever | null` and it would work identically. `T` is convention, short for "Type", the way `i` is convention in a for-loop.

Substitution is the whole mental model. `Nullable<User>` means: **take the definition, and wherever you see** `T`**, write** `User` **instead.**

```ts
Nullable<T>    =  T    | null
Nullable<User> =  User | null
```

That's it. That's generics. Every scary-looking generic in the wild is that same substitution, just nested.

You've been using them already:

```ts
Array<string>       // an array whose items are strings — same as string[]
Promise<User>       // a promise that will produce a User
Record<string, number>  // an object with string keys and number values
Pick<User, 'email'>     // (section 5) — it takes TWO type arguments
```

* * *

### `(x) => y` — a function type: the shape of a callable

You can name the *signature* of a function, without writing the function.

```ts
type Handler = (event: MouseEvent) => void;
```

That reads: **a thing you can call with a** `MouseEvent`**, which gives you back nothing.** (`void` = "returns nothing useful, ignore the return value.")

Let's say five components all take an `onClick`. Name the shape once:

```ts
type Handler = (event: MouseEvent) => void;

interface ButtonProps {
  onClick: Handler;
  onHover: Handler;
}

const handleClick: Handler = (event) => {
  console.log(event.clientX); // ✅ TypeScript knows `event` is a MouseEvent
};
```

Notice you never annotated `event` in `handleClick`. You didn't have to — the type flowed *in* from `Handler`. That's called **contextual typing**, and it's most of why annotating your props properly is worth the effort: you write the type once, and every callback beneath it becomes free.

* * *

### `keyof` — give me the key names, as a union

`keyof` takes an object type and hands you back **the list of its property names**, as a union of strings.

```ts
interface User {
  id: string;
  email: string;
  createdAt: Date;
}

type UserKey = keyof User;
// → 'id' | 'email' | 'createdAt'
```

That's the whole operation. `keyof` in, union of key names out.

Compare it to something you already know:

```ts
Object.keys(user)  // value space, at runtime  → ['id', 'email', 'createdAt']
keyof User         // type space, at compile time → 'id' | 'email' | 'createdAt'
```

Same idea, different universe. One gives you an array of strings you can loop over. The other gives you a union of strings the compiler can check against.

Why you'd want it — let's say you're writing a sort function:

```ts
const sortBy = (users: User[], key: keyof User) => { /* ... */ };

sortBy(list, 'email');     // ✅
sortBy(list, 'emial');     // ❌ Argument of type '"emial"' is not assignable
                           //    to parameter of type 'keyof User'.
sortBy(list, 'password');  // ❌ that field doesn't exist on User
```

If you'd typed `key: string`, all three would compile and two would silently sort by nothing. `keyof` means **"a real key of this thing, not just any old string."** And you get autocomplete on the argument.

* * *

### `User['id']` — indexed access: give me the type *of* that property

Square brackets, but in the type space.

```ts
interface User {
  id: string;
  createdAt: Date;
}

type Id = User['id'];               // → string
type Timestamp = User['createdAt']; // → Date
```

Read it exactly like property access, because it *is* property access — just one universe up:

```ts
user['id']   // value space → the actual id, "abc-123"
User['id']   // type space  → the TYPE of that field, string
```

Why bother, when you could just write `string`?

Let's say six months from now you brand your IDs for safety (section 15):

```ts
interface User {
  id: UserId;  // ← changed from string
  createdAt: Date;
}
```

Every place that wrote `type Id = User['id']` **updates itself**. Every place that hand-wrote `string` is now quietly, invisibly wrong. That's the whole game: **derive, don't duplicate.**

And now the part that pays off later — you can index with a **union** of keys, and you get back a **union** of the types:

```ts
type Values = User['id' | 'createdAt'];  // → string | Date
```

Feed it *every* key at once, and you get every value type:

```ts
type AllValues = User[keyof User];  // → string | Date
//                    ↑
//            'id' | 'createdAt'
```

Sit with that line for a second, because **it is section 4** — the `(typeof X)[keyof typeof X]` monster — with the scary part removed. Same two operators, same order. You already understand it.

* * *

So: an `interface` can only ever describe **the shape of an object**. A `type` can describe *any* type — a union, a tuple, a function, or something computed from another type entirely.

* * *

## 3\. interface vs type — the actual decision

|  | `interface` | `type` |
| --- | --- | --- |
| Re-declarable (merging) | ✅ yes | ❌ no |
| Can be a union / tuple / primitive | ❌ no | ✅ yes |
| Extending | `extends` | `&` intersection |
| Conflicting members | **compile error** | silently becomes `never` |

That last row is the one worth internalizing.

```ts
interface A { id: string }
interface B extends A { id: number }
// Error: Interface 'B' incorrectly extends 'A'.
//        Type 'number' is not assignable to type 'string'.
```

TypeScript stops you. Now the same thing with an intersection:

```ts
type A = { id: string };
type B = A & { id: number };

const b: B = { id: ??? }; // id is `string & number` → never. Nothing satisfies it.
```

No error at the declaration. The bug is *deferred* to whoever tries to construct a `B`, and the error they get ("Type 'string' is not assignable to type 'never'") points at their code, not at yours.

* * *

## 4\. `(typeof CustomType)[keyof typeof CustomType]` — the one that makes it click

Read it inside out, and keep the two worlds separate: `CUSTOM_TYPE` the value (a real object at runtime) and the type it happens to have. Every operator here just moves between those worlds.

Start with the value:

```ts
const CUSTOM_TYPE = {
  FIRST: 'first',
  SECOND: 'second',
  THIRD: 'third',
} as const
```

`typeof CUSTOM_TYPE` — "give me the type of that value". This is the type-level `typeof`, nothing to do with the JS `typeof` that returns `'string'`. It's the bridge from value-world to type-world.

```typescript
// typeof CUSTOM_TYPE
{
  readonly FIRST: 'first'
  readonly SECOND: 'second'
  readonly THIRD: 'third'
}
```

`keyof typeof CUSTOM_TYPE` — `keyof` takes a type and hands back a union of its keys:

```ts
'FIRST' | 'SECOND' | 'THIRD'
```

`(typeof X)[keyof typeof X]` — an indexed access. Same syntax as `obj[key]` in JS, but at the type level: index a type with a key, get the value type at that key.

I like to imagine `typeof X` as an *object* with keys. and `keyof` that object provides those *keys*. this reads like `obj[key]`

```typescript
type A = (typeof CUSTOM_TYPE)['FIRST']  // 'first'
```

The trick is that indexing with a *union* of keys gives you a union of the value types:

```typescript
(typeof CUSTOM_TYPE)['FIRST' | 'SECOND' | 'THIRD']
// → 'first' | 'second' | 'third'
```

So the whole thing reads: "the union of all value types in `CUSTOM_TYPE`." In plain JS terms, it's `Object.values()`, at the type level.

The parens around `typeof CUSTOM_TYPE` are only there for precedence — without them `typeof X[...]` parses as `typeof (X[...])`, which is a different thing entirely. That's a big part of why the line looks so dense: you're reading `X` twice, once inside parens and once inside `keyof`.

If it helps, break it up — the meaning survives intact:

```ts
type CustomTypeMap = typeof CUSTOM_TYPE
type CustomTypeKey = keyof CustomTypeMap              // 'FIRST' | 'SECOND' | 'THIRD'
export type CustomType = CustomTypeMap[CustomTypeKey] // 'first' | 'second' | 'third'
```

I'd argue that's better code anyway — the intermediate names are free and they document the two halves.

Why bother at all: it keeps the type derived from the object. Add a fourth member to `CUSTOM_TYPE` and the union updates itself, and every `switch` that was exhaustive now fails to compile until you handle the new case. Write the union by hand and it drifts.

There's also a **generic helper** worth stashing, since you'll hit this pattern constantly:

```ts
type ValueOf<T> = T[keyof T]

export type CustomType = ValueOf<typeof CUSTOM_TYPE>
```

Same thing, reads like English.

### Same trick, other shapes

```ts
const ROUTES = ['/home', '/settings', '/billing'] as const;
type Route = (typeof ROUTES)[number]; // "/home" | "/settings" | "/billing"
```

An array is an object whose keys are numbers, so indexing its type with `number` gives you the union of its elements. Same mechanism.

* * *

## 5\. Pick, Omit

Let's say you have this:

```ts
interface User {
  id: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}
```

Your API route must never leak `passwordHash`. Don't hand-write a second interface — it will drift from the first one within a week.

```ts
type PublicUser = Omit<User, 'passwordHash'>;
// { id: string; email: string; createdAt: Date }

type UserCredentials = Pick<User, 'email' | 'passwordHash'>;
// { email: string; passwordHash: string }
```

`Pick` = keep these keys. `Omit` = drop these keys. They're **derivations**: add a field to `User` tomorrow and `PublicUser` gets it for free, while `UserCredentials` stays exactly as narrow as it was.

The subtle difference: `Pick<User, 'emial'>` is a **compile error** (the key must exist), while `Omit<User, 'emial'>` silently does nothing — `Omit`'s key parameter isn't constrained to `keyof T`. So typos in `Omit` are invisible. Prefer `Pick` when the safe list is short; use `Omit` when the removal list is short and *review it*.

Neighbours you'll reach for constantly:

```ts
Partial<User>            // every field optional  — patch payloads
Required<User>           // every field required
Readonly<User>           // every field readonly
Record<Status, string[]> // { draft: string[]; published: string[]; ... }
ReturnType<typeof fn>    // what fn returns
Parameters<typeof fn>    // its args as a tuple
Awaited<ReturnType<typeof fetchUser>> // unwrap the Promise
Exclude<Status, 'archived'>           // remove members from a union
Extract<Status, 'draft' | 'x'>        // keep members of a union
NonNullable<T>                        // T minus null and undefined
```

Note `Omit`/`Pick` work on object types; `Exclude`/`Extract` work on unions. Same idea, different universe of "members".

* * *

## 6\. `interface extends otherInterface`

Let's say every entity in your DB has the same audit columns:

```ts
interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

interface User extends Entity {
  email: string;
}

interface Document extends Entity {
  title: string;
  ownerId: User['id']; // indexed access — if id becomes a branded type, this follows
}
```

`extends` here means "has at least everything `Entity` has, plus these." It's not inheritance in the OOP sense — there's no runtime, no prototype chain. It's a **subtyping assertion**, checked at compile time and erased.

You can extend multiple interfaces, and you can extend a `type` as long as it's object-shaped:

```ts
type Timestamped = { createdAt: Date };
interface Post extends Timestamped, Entity { title: string }
```

And remember section 3: if `Post` declares a member that conflicts with `Entity`, you get an error at the declaration — which is exactly what you want.

* * *

## 7\. `A & B` and `A | B`

```ts
type Draggable = { onDrag: () => void };
type Resizable = { onResize: () => void };

type Widget = Draggable & Resizable; // must have BOTH methods
type Event = Draggable | Resizable;  // has at LEAST one of them
```

Here's the part that confuses people: `&` **(AND) gives you a bigger object;** `|` **(OR) gives you a smaller usable object.**

That's because a type is a *set of possible values*.

*   `A & B` = the set of values that are simultaneously an `A` and a `B`. To qualify, a value needs *more* properties. **More constraints, fewer values.**
    
*   `A | B` = the set of values that are an `A` *or* a `B`. More values qualify — so TypeScript knows *less* about any given one.
    

```ts
const handle = (e: Draggable | Resizable) => {
  e.onDrag(); // Error: Property 'onDrag' does not exist on type 'Resizable'.
};
```

Of course it doesn't. TypeScript doesn't know *which* one you have. You only get to touch the properties that exist on **every** member of the union — until you narrow it (section 10).

* * *

## 8\. The mutually-exclusive props pattern (`never` as a "you may not pass this")

Let's say you have a delete function that takes either one id, or many. Naively:

```ts
interface DeleteArgs {
  id?: string;
  ids?: string[];
}

deleteItems({});                          // 🙃 valid, deletes nothing
deleteItems({ id: '1', ids: ['2', '3'] }); // 🙃 valid, which wins?
```

The type says "both optional" when you meant "exactly one." Encode the *logic*:

```ts
type DeleteArgs =
  | { id: string;  ids?: never }
  | { id?: never;  ids: string[] };

deleteItems({ id: '1' });              // ✅
deleteItems({ ids: ['1', '2'] });      // ✅
deleteItems({ id: '1', ids: ['2'] });  // ❌ Type 'string[]' is not assignable to type 'never'.
deleteItems({});                       // ❌ neither branch satisfied
```

Why does this work? `never` is the type with **zero possible values** (section 12). So `ids?: never` reads as: *this key is optional, and if you do provide it, there is no value on earth that will type-check.* It's a compile-time "this key must be absent."

**Important detail:** the `?` is load-bearing. `{ id: string; ids: never }` would make the branch *impossible to satisfy*, because `ids` would be a required property that can never be given a value. Optional-`never` = "must be absent." Required-`never` = "this object cannot exist."

Inside the function, narrow with a truthiness check or a discriminant:

```ts
const deleteItems = (args: DeleteArgs) => {
  const idList = args.ids ?? [args.id];
  // ...
};
```

If you find yourself writing many of these, add an explicit **discriminant** instead — it's cheaper for the compiler and clearer for humans:

```ts
type DeleteArgs =
  | { mode: 'single'; id: string }
  | { mode: 'bulk';   ids: string[] };
```

Now `mode` is a tag. TypeScript builds an internal map from `'single' → first member`, `'bulk' → second member`, so narrowing becomes a lookup rather than an assignability check against each shape. That's the "cheaper for the compiler" part.

Then check like so:

```ts
const remove = (args: DeleteArgs) => {
  if (args.mode === 'single') {
    return deleteOne(args.id);
  }
  return deleteMany(args.ids);  // narrowed to 'bulk' by elimination
};
```

* * *

## 9\. `as const` — and what happens without it

Let's say you write a config object:

```ts
const config = {
  env: 'production',
  retries: 3,
  hosts: ['a.com', 'b.com'],
};
```

What did TypeScript infer?

```ts
// {
//   env: string;          ← not "production"
//   retries: number;      ← not 3
//   hosts: string[];      ← mutable, element type string
// }
```

This is **widening**. Because object properties are mutable, TypeScript assumes you might later write `config.env = 'staging'`, so it widens the literal `'production'` up to `string`. Which means this fails:

```ts
type Env = 'production' | 'staging';
const env: Env = config.env; // Error: Type 'string' is not assignable to type 'Env'.
```

Now add `as const`:

```ts
const config = {
  env: 'production',
  retries: 3,
  hosts: ['a.com', 'b.com'],
} as const;

// {
//   readonly env: "production";
//   readonly retries: 3;
//   readonly hosts: readonly ["a.com", "b.com"];
// }
```

`as const` says: **"nothing here will ever be reassigned, so don't widen anything."** Every property becomes `readonly`, every literal stays literal, every array becomes a `readonly` tuple.

Two consequences worth knowing:

1.  It's what makes section 4's `(typeof X)[keyof typeof X]` produce `"draft" | "published"` instead of a useless `string`.
    
2.  `readonly string[]` is **not** assignable to `string[]`. If a function takes `string[]`, passing `config.hosts` is an error. Widen the parameter to `readonly string[]` — you almost never actually needed a mutable array.
    

### Its better half: `satisfies`

`as const` and a type annotation each do half the job, and they get in each other's way.

**Annotate, and you get checking — but you lose the literals:**

```ts
const ROUTES: Record<string, `/${string}`> = {
  home: '/home',
  billing: 'billing', // ❌ caught: no leading slash. Good.
};

ROUTES.home; // `/${string}` — not '/home'. You were told the type; TS forgot the value.
```

An annotation is a command: *you are this type now*. TypeScript takes you at your word and stops looking at what you actually wrote.

**Use** `as const`**, and you keep the literals — but nothing is checked:**

```ts
const ROUTES = {
  home: '/home',
  billing: 'billing', // ✅ compiles. Nobody asked whether this was a valid path.
} as const;

ROUTES.home; // '/home'. Exactly what you wrote.
```

`satisfies` **is the missing piece.** It checks the value against a constraint and then throws the constraint away — it never becomes the variable's type:

```ts
const ROUTES = {
  home: '/home',
  billing: '/billing',
} as const satisfies Record<string, `/${string}`>;
```

Two jobs, one line:

*   `as const` → freezes the literals. `ROUTES.home` is `'/home'`, and `keyof typeof ROUTES` is `'home' | 'billing'`, not `string`.
    
*   `satisfies` → audits them. Drop the leading slash and the build fails.
    

Which means this now works, and is trustworthy:

```ts
type Route = (typeof ROUTES)[keyof typeof ROUTES];
// '/home' | '/billing'
```

With the annotation, `Route` would have been `` `/${string}` `` — useless as a union. With `as const` alone, a typo would have quietly become a member of it.

**Rule of thumb:** `an annotation` *declares* a type. `as const` *preserves* one. `satisfies` *verifies* one without replacing it. You want the last two together.

One caveat worth knowing: `satisfies` on its own sometimes preserves literals too, depending on the constraint. Don't rely on it. If you want literals, say `as const` — then the behavior is a rule, not a coincidence.

* * *

## 10\. Type guards (`value is Type`) — teaching the compiler what you know

Let's say you're handling something from a `catch` or a `JSON.parse`. You know it's a `User` because you checked. TypeScript doesn't.

A **type predicate** is a function whose return type is `arg is Type`. When it returns `true`, the compiler narrows the argument in the calling scope.

```ts
const isUser = (value: unknown): value is User =>
  typeof value === 'object' &&
  value !== null &&
  'id' in value &&
  typeof (value as User).id === 'string';

const handle = (payload: unknown) => {
  if (!isUser(payload)) return;
  payload.email; // ✅ payload is User in here
};
```

The `is` is a **promise you are making to the compiler**. If your check is wrong, TypeScript will happily believe you and you'll crash at runtime. It's a controlled `as`, not a proof.

### The narrowings you get for free

```ts
if (typeof x === 'string')      // primitives
if (Array.isArray(x))           // arrays
if (x instanceof Error)         // classes
if ('id' in x)                  // the `in` operator, for object unions
if (x !== null)                 // truthiness / null checks
```

### Discriminated unions — the pattern to reach for first

Give every member of a union a shared literal field. TypeScript narrows on it automatically, and you write zero guards:

```ts
type Result =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };

const render = (result: Result) => {
  switch (result.status) {
    case 'loading': return spinner();
    case 'success': return view(result.data);   // ✅ .data exists only here
    case 'error':   return alert(result.error); // ✅ .error exists only here
  }
};
```

This is 90% of the value of TypeScript in one construct. It makes illegal states *unrepresentable* — you cannot have a `Result` that is both loading and holding data.

* * *

## 11\. Recursive types

For example, you have a comment tree, a nav menu, a file system:

```ts
interface Comment {
  id: string;
  body: string;
  replies: Comment[]; // fine — self-reference through a property
}
```

Recursion gets powerful when combined with mapped types.

Here's a shape you've written a hundred times:

```ts
type User = {
  id: string;
  profile: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
};
```

Now write the type for a PATCH payload — the same shape, but every field optional, because the client only sends what changed.

Your first instinct is the built-in:

```ts
type PatchUser = Partial<User>;
```

Hover it, and you get this:

```ts
{
  id?: string;
  profile?: {
    name: string;                            // ← still required
    address: { city: string; zip: string };  // ← still required
  };
}
```

It made the *top-level* keys optional and stopped. Which means the payload you actually want to send doesn't compile:

```ts
const patch: PatchUser = {
  profile: { name: 'Ada' },
};
// ❌ Property 'address' is missing
```

To rename a user you'd have to resend their entire address. That's the problem. Now let's look at why `Partial` behaves this way, because the fix falls out of it.

## `Partial` is one line, and it only loops once

This is the whole thing, straight from the standard library:

```ts
type Partial<T> = { [K in keyof T]?: T[K] };
```

That's a **mapped type**. Read it as a for-loop that builds a new object type:

```plaintext
for each key K in keyof T:
  emit key K, but optional (?)
  give it the type T[K] — whatever it was before
```

Applied to `User`, the loop runs twice:

| K | `T[K]` | emits |
| --- | --- | --- |
| `'id'` | `string` | `id?: string` |
| `'profile'` | `{ name: ...; address: ... }` | `profile?: { name: ...; address: ... }` |

Look at the second row. It made the `profile` **key** optional. But the **value** — that nested object — was copied across verbatim. `T[K]` hands it over untouched. Nobody ever went inside it.

That's the bug in one sentence: **the loop runs once, at one level.** The nested object is just a value being copied, not a thing being processed.

## The fix: process the value instead of copying it

So don't copy `T[K]`. Run it through the same transformation again:

```ts
type DeepPartial<T> = {[K in keyof T]?: DeepPartial<T[K]>};
//                                       ^^^^^^^^^^^^^^^^^
//  instead of T[K], it's DeepPartial<T[K]>
```

One token changed. `T[K]` became `DeepPartial<T[K]>`.

That's the recursion, and it's not a clever trick — it's the obvious move once you see that `T[K]` was the place the descent *should* have happened and didn't.

## But now it never stops

Run `DeepPartial<User>` with the definition above and follow the `id` key:

*   `id`'s type is `string`
    
*   so we call `DeepPartial<string>`
    
*   which expands to `{ [K in keyof string]?: DeepPartial<string[K]> }`
    
*   and `keyof string` is... `'charAt' | 'slice' | 'length' | 'toUpperCase' | ...`
    

You get an object with optional `charAt` and `toUpperCase` properties. Which is not a string, and not what anyone wanted.

The loop needs a floor. **Primitives are the floor** — there's nothing inside a `string` to make optional, so when you hit one, hand it back and stop:

```ts
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }   // it's an object — go in
  : T;                                        // it's a primitive — done
```

That conditional is the base case. Every recursive type needs one, and it's almost always "am I still looking at something with keys?"

## Trace it end to end

`DeepPartial<User>`. `User` is an object, so loop over its keys:

`id` → `DeepPartial<string>` → `string` is not an object → returns `string`. Emits `id?: string`. ✅ floor reached.

`profile` → `DeepPartial<{ name; address }>` → that *is* an object → loop again:

    `name` → `DeepPartial<string>` → `string`. ✅ floor.

    `address` → `DeepPartial<{ city; zip }>` → object → loop again:

        `city` → `string`. ✅ floor.         `zip` → `string`. ✅ floor.

Every branch bottoms out at a primitive. Unwind, and every `?` that got emitted on the way down is still there:

```ts
type PatchUser = DeepPartial<User>;
// {
//   id?: string;
//   profile?: {
//     name?: string;
//     address?: {
//       city?: string;
//       zip?: string;
//     };
//   };
// }
```

And the payload from the top now compiles:

```ts
const patch: PatchUser = {
  profile: { name: 'Ada' },   // ✅
};

const deep: PatchUser = {
  profile: { address: { city: 'Bucharest' } },  // ✅ two levels down, one field
};
```

Add a fifth level of nesting to `User` tomorrow and you change nothing. The type follows the shape wherever it goes — that's what you bought.

## The gotcha nobody warns you about

`T extends object` is broader than "plain object." It's true for **arrays, functions,** `Date`**,** `Map`**, class instances** — everything that isn't a primitive. So `DeepPartial` marches straight into them:

```ts
type T1 = DeepPartial<Date>;
// { toISOString?: () => string; getTime?: () => number; ... }
// A Date with optional methods. It is no longer a Date.

type T2 = DeepPartial<string[]>;
// { length?: number; push?: ...; map?: ... }
// It is no longer an array.
```

The floor is too low. Raise it — anything you don't want to descend into becomes part of the base case:

```ts
type Primitive = string | number | boolean | bigint | symbol | null | undefined;

type DeepPartial<T> = T extends Primitive | Date | RegExp
  ? T                                          // atomic — return as-is
  : T extends ReadonlyArray<infer U>
    ? ReadonlyArray<DeepPartial<U>>            // array — recurse on the element type
    : T extends object
      ? { [K in keyof T]?: DeepPartial<T[K]> } // plain object — recurse on values
      : T;
```

Order matters: conditionals resolve top-down, first match wins, so the escape hatches sit above the general object case.

Whether arrays should be descended into at all is a judgment call. For a PATCH payload, arrays are usually atomic — you replace the whole list or you omit it, you don't partially patch element 3. In that case:

```ts
: T extends ReadonlyArray<unknown>
  ? T   // leave it alone
```

## Same skeleton, different transformation

Once the pattern is in your hands — *conditional for the floor, mapped type for the descent,* `T[K]` *for the recursion* — you get the whole family by changing one token:

```ts
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }  // add readonly
  : T;

type DeepRequired<T> = T extends object
  ? { [K in keyof T]-?: DeepRequired<T[K]> }         // -? strips optionality
  : T;

type DeepNullable<T> = T extends object
  ? { [K in keyof T]: DeepNullable<T[K]> | null }    // union in a null
  : T;
```

What you need to know before you start applying this everywhere:

*   Recursion is depth-limited (~50 levels) — deep recursive *conditional* types can hit "Type instantiation is excessively deep and possibly infinite."
    
*   Heavy recursive types are a real compile-time cost. If your IDE gets sluggish, look here first.
    

* * *

## 12\. `any` vs `unknown` vs `never`

Think of a type as a set of values:

| Type | Set | Meaning |
| --- | --- | --- |
| `unknown` | all values | "Something's here. I don't know what." |
| `never` | no values | "Nothing can be here. Unreachable." |
| `any` | not a set | "Stop checking." |

### `any` is BAD

```ts
const data: any = await res.json();

data.user.profile.nmae;  // no error (typo)
data.foo();              // no error
data + 1;                // no error

const email: string = data.user;  // no error — `any` just became `string`
```

That last line is the damage. `any` doesn't stay put. It flows through assignments and return values into modules that never mentioned `any`. One `any` at an API boundary can hollow out a whole feature.

### `unknown` is the honest version

```ts
const data: unknown = await res.json();

data.user;  // ❌ 'data' is of type 'unknown'
```

Anything can go *into* `unknown`. Nothing comes *out* until you prove what it is — because as far as TypeScript knows, `data` could be `null`, or `42`, and neither has a `.user`.

**Narrowing** is that proof. You write a runtime check; TypeScript watches it and gives you a better type inside the branch:

```ts
const value: unknown = JSON.parse(raw);

if (typeof value === "string") {
  value.toUpperCase();  // ✅ inside here, value is `string`
}

value.toUpperCase();    // ❌ outside, it's `unknown` again
```

The check is what buys the type. No check, no access.

For object shapes, write the check once and label it with `is`:

```ts
type User = { id: string; email: string };

const isUser = (v: unknown): v is User =>
  typeof v === "object" && v !== null &&
  typeof (v as User).id === "string" &&
  typeof (v as User).email === "string";

const data: unknown = await res.json();

if (!isUser(data)) throw new Error("Bad payload");

data.email;  // ✅ data is `User` from here on
```

`any` deletes the check. `unknown` demands it. Same runtime risk either way — opposite compile-time posture. That's why `unknown` belongs at every boundary: `fetch`, `JSON.parse`, `localStorage`, `postMessage`, `catch (e: unknown)`.

`unknown` in, guard, `User` out. (Which is exactly what Zod automates — section 15.)

### `never` is a proof, not a mistake

`never` is where a value is impossible:

```ts
const fail = (msg: string): never => { throw new Error(msg); };  // never returns
type Impossible = string & number;                               // empty set
```

Its killer app is exhaustiveness. Because `never` is the empty set, *nothing* is assignable to it — so a function that demands a `never` argument only compiles when you've genuinely run out of cases:

```ts
type Result =
  | { status: "loading" }
  | { status: "success"; data: Data }
  | { status: "error"; error: string };

const assertNever = (value: never): never => {
  throw new Error(`Unhandled: ${JSON.stringify(value)}`);
};

const render = (result: Result) => {
  switch (result.status) {
    case "loading": return spinner();
    case "success": return view(result.data);
    case "error":   return alert(result.error);
    default:        return assertNever(result);  // ✅ compiles
  }
};
```

Each `case` narrows one variant away. By `default`, all three are gone and `result` is `never` — which is the only thing `assertNever` accepts.

Now add `{ status: "idle" }` to `Result`. In `default`, `result` is `{ status: "idle" }`, not `never`, and the build breaks — right at the switch you forgot to update.

That's the whole point: a compile error at every place you need to think.

* * *

## 13\. Why enums are bad

```ts
enum Status {
  Draft = 'draft',
  Published = 'published',
}
```

### They don't disappear

Every other type construct is *erased* at compile time. Enums are not. They emit runtime JavaScript:

```js
var Status;
(function (Status) {
    Status["Draft"] = "draft";
    Status["Published"] = "published";
})(Status || (Status = {}));
```

That's an IIFE that mutates an object. Bundlers see a function call with side effects on a shared binding and, conservatively, **keep it** — even if you imported the enum only to reference one member in a type position. You now ship runtime code for something you used as a type. Multiply by every enum in a shared `types.ts` and it adds up.

Numeric enums are worse: they emit a **reverse mapping** too (`Status[0] === "Draft"`), so the object is twice the size.

### They're nominal, in a structural language

TypeScript is structural — a thing that looks like a `User` *is* a `User`. Enums break that rule:

```ts
const publish = (status: Status) => { /* ... */ };

publish('draft');        // ❌ Argument of type '"draft"' is not assignable to 'Status'.
publish(Status.Draft);   // ✅ only this works
```

Your enum is now viral: every caller, every test fixture, every mock has to import it. You can't just write the string you can plainly see in the database.

### `const enum` is not the fix

`const enum` inlines and emits nothing — but it requires whole-program type information, so it **breaks under isolatedModules**, which means it breaks under Babel, esbuild, SWC, and anything Vite-adjacent. TS 5.0 added `preserveConstEnums`/erasable-syntax pressure precisely because this feature doesn't fit modern build pipelines. And `--erasableSyntaxOnly` (TS 5.8, for Node's native type-stripping) bans enums outright.

### Just use `as const` + the section-4 trick

```ts
export const StatusEnum = {
  Draft: 'draft',
  Published: 'published',
} as const;

export type Status = (typeof StatusEnum)[keyof typeof StatusEnum]; // "draft" | "published"
```

You get:

```ts
publish(StatusEnum.Draft); // ✅ autocomplete, single source of truth
publish('draft');      // ✅ also fine — it's just a string
```

*   Zero enum machinery. A plain object — tree-shakeable, inlinable, JSON-serializable.
    
*   Structural, so raw strings from your DB/API just work.
    
*   Same DX: `Status.` still autocompletes.
    
*   The type and the value share a name, so consumers import one symbol.
    

If you don't even need the runtime object, a bare union is enough: `type Status = 'draft' | 'published';`

* * *

## 14\. Zod, and `z.infer`

Everything above happens at compile time. At runtime, an API response is a lie until you check it.

```ts
const res = await fetch('/api/user');
const user: User = await res.json(); // ← this annotation is a wish, not a check
```

`res.json()` returns `any`. You annotated it `User` and TypeScript relaxed. If the backend renamed `email` to `emailAddress` last night, you find out in production.

Zod flips the direction: **define the schema once, derive the type from it.**

```ts
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  plan: z.enum(['free', 'pro']),
  createdAt: z.coerce.date(),
  posts: z.array(z.object({ id: z.string(), title: z.string() })).default([]),
});

type User = z.infer<typeof UserSchema>;
// {
//   id: string;
//   email: string;
//   plan: "free" | "pro";
//   createdAt: Date;
//   posts: { id: string; title: string }[];
// }
```

Look at what `z.infer<typeof UserSchema>` is doing — it's exactly section 4. `UserSchema` is a **value**. `typeof UserSchema` walks it into the type space. `z.infer` is a conditional type that reaches inside and pulls out the shape it describes. Same door, same move.

Now the type can't drift from the validation, because **the validation is the source of truth**:

```ts
const fetchUser = async (id: string): Promise<User> => {
  const res = await fetch(`/api/users/${id}`);
  return UserSchema.parse(await res.json()); // throws on the boundary, not 3 layers deep
};
```

`parse` throws. `safeParse` doesn't — it returns a discriminated union, which you already know how to narrow:

```ts
const result = UserSchema.safeParse(payload);

if (!result.success) {
  return { error: result.error.flatten() }; // ✅ .error only exists here
}

result.data; // ✅ fully typed User, guaranteed to match at runtime
```

One more thing worth knowing: when a schema **transforms** (`.coerce`, `.default`, `.transform`), input and output types differ.

```ts
type UserInput  = z.input<typeof UserSchema>;  // createdAt: string | Date, posts optional
type UserOutput = z.output<typeof UserSchema>; // createdAt: Date, posts required
// z.infer === z.output
```

Use it at every boundary where data enters your program: HTTP responses, form submissions, `process.env`, webhook payloads, `localStorage`. `unknown` in → `parse` → typed value out.

* * *

## 15\. Some "nice to know"s

### Generics are just parameters for types

If a function's return type depends on its input type, that's a generic:

```ts
const first = <T,>(items: T[]): T | undefined => items[0];

first([1, 2, 3]);       // number | undefined
first(['a', 'b']);      // string | undefined
```

Constrain them with `extends` — read it as "T must be at least this":

```ts
const getId = <T extends { id: string }>(entity: T) => entity.id;
```

### `keyof` + generics = type-safe property access

```ts
const prop = <T, K extends keyof T>(obj: T, key: K): T[K] => obj[key];

prop(user, 'email'); // string
prop(user, 'emial'); // ❌ Argument of type '"emial"' is not assignable to 'keyof User'
```

`K extends keyof T` constrains the key to actually exist; `T[K]` returns whatever *that specific key's* type is. The compiler tracks the relationship between two arguments.

### Conditional types + `infer`

```ts
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<User>>; // User
type B = Unwrap<string>;        // string
```

`T extends X ? Y : Z` is a ternary in the type space. `infer U` is "pattern-match here and give the captured piece a name." That's `Awaited`, `ReturnType`, and `z.infer` — all the same machinery.

### Template literal types

```ts
type Route = `/${string}`;
type EventName = `on${Capitalize<'click' | 'focus'>}`; // "onClick" | "onFocus"
```

Strings you can compute with. Combine with key remapping for things like generating getters:

```ts
type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};
// Getters<{ id: string }> → { getId: () => string }
```

### Branded types (nominal typing, when you actually want it)

Let's say `userId` and `postId` are both `string`, and one day you pass the wrong one. TypeScript can't help — structurally, they're identical. So make them different:

```ts
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

const findUser = (id: UserId) => { /* ... */ };

const postId = 'abc' as PostId;
findUser(postId); // ❌ 'PostId' is not assignable to 'UserId'
```

The `__brand` property doesn't exist at runtime — it's a phantom, purely to make the two types structurally different. Zero cost, real safety at boundaries where IDs get swapped.

### `!` (non-null assertion) is `any`'s little brother

```ts
const user = users.find((u) => u.id === id)!; // "trust me"
```

You've silenced the compiler, and you get a `TypeError: Cannot read properties of undefined` instead of a helpful error. Handle the `undefined`, or throw explicitly so the failure has a message. Reserve `!` for cases you can prove locally in the next line.

### `Prettify` — the debug helper

Intersections and mapped types show up in tooltips as unreadable soup. This forces the compiler to flatten them:

```ts
type Prettify<T> = { [K in keyof T]: T[K] } & {};

type Ugly = Omit<User, 'id'> & { role: string };  // hover: a mess
type Nice = Prettify<Ugly>;                       // hover: the actual flat object
```

Does nothing at runtime, changes nothing semantically, saves your eyes.

### Turn on `strict`

If `strict: true` isn't in your `tsconfig.json`, most of this document is decorative. Also worth adding:

```jsonc
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true, // arr[0] is T | undefined — because it is
    "exactOptionalPropertyTypes": true, // `{ a?: string }` ≠ `{ a: undefined }`
    "noImplicitOverride": true,
    "isolatedModules": true
  }
}
```

`noUncheckedIndexedAccess` is the one people fight and then thank you for.

* * *

## The whole thing, compressed

1.  **Two universes.** `typeof` is the door from value space into type space; `keyof` and indexed access let you walk around once you're through.
    
2.  **Types are sets.** `&` shrinks the set (bigger objects), `|` grows it (less you can do). `unknown` is everything, `never` is nothing, `any` is a surrender.
    
3.  **Derive, never duplicate.** `Pick`, `Omit`, `typeof`, `z.infer` — one source of truth, everything else follows from it.
    
4.  **Make illegal states unrepresentable.** Discriminated unions and optional-`never` beat a bag of optional booleans, every time.
    
5.  **Validate at the boundary, trust inside it.** `unknown` in, Zod parse, typed value out.
    
6.  **Prefer things that vanish.** If a construct emits runtime JavaScript for a compile-time concern (looking at you, `enum`), there's usually a plain object that does it better.
