The only TypeScript you need to appear knowledgeable
And to improve your code

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,keyoflive in the type space.typeofis 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:
// 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:
// 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:
// globals.d.ts — no imports, no exports, deliberately
interface Window {
__APP_STATE__: AppState; // ✅ merges into lib.dom's Window
}
// app.ts
window.__APP_STATE__; // ✅ compiles
Now add one line to the bottom of globals.d.ts:
// globals.d.ts - adds an export in the file
export {};
// 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:
// 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:
// 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)
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".
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.
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:
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":
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":
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:
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:
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:
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:
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:
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.
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:
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.
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:
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.
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:
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:
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.
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:
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):
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:
type Values = User['id' | 'createdAt']; // → string | Date
Feed it every key at once, and you get every value type:
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.
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:
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:
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.
// 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:
'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]
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:
(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:
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:
type ValueOf<T> = T[keyof T]
export type CustomType = ValueOf<typeof CUSTOM_TYPE>
Same thing, reads like English.
Same trick, other shapes
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:
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.
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:
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:
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:
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
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 anAand aB. To qualify, a value needs more properties. More constraints, fewer values.A | B= the set of values that are anAor aB. More values qualify — so TypeScript knows less about any given one.
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:
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:
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:
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:
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:
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:
const config = {
env: 'production',
retries: 3,
hosts: ['a.com', 'b.com'],
};
What did TypeScript infer?
// {
// 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:
type Env = 'production' | 'staging';
const env: Env = config.env; // Error: Type 'string' is not assignable to type 'Env'.
Now add as const:
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:
It's what makes section 4's
(typeof X)[keyof typeof X]produce"draft" | "published"instead of a uselessstring.readonly string[]is not assignable tostring[]. If a function takesstring[], passingconfig.hostsis an error. Widen the parameter toreadonly 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:
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:
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:
const ROUTES = {
home: '/home',
billing: '/billing',
} as const satisfies Record<string, `/${string}`>;
Two jobs, one line:
as const→ freezes the literals.ROUTES.homeis'/home', andkeyof typeof ROUTESis'home' | 'billing', notstring.satisfies→ audits them. Drop the leading slash and the build fails.
Which means this now works, and is trustworthy:
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.
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
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:
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:
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:
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:
type PatchUser = Partial<User>;
Hover it, and you get this:
{
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:
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:
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:
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:
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 isstringso we call
DeepPartial<string>which expands to
{ [K in keyof string]?: DeepPartial<string[K]> }and
keyof stringis...'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:
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:
type PatchUser = DeepPartial<User>;
// {
// id?: string;
// profile?: {
// name?: string;
// address?: {
// city?: string;
// zip?: string;
// };
// };
// }
And the payload from the top now compiles:
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:
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:
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:
: 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:
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
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
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:
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:
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:
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:
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
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:
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:
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
export const StatusEnum = {
Draft: 'draft',
Published: 'published',
} as const;
export type Status = (typeof StatusEnum)[keyof typeof StatusEnum]; // "draft" | "published"
You get:
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.
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.
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:
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:
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.
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:
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":
const getId = <T extends { id: string }>(entity: T) => entity.id;
keyof + generics = type-safe property access
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
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
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:
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:
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
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:
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:
{
"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
Two universes.
typeofis the door from value space into type space;keyofand indexed access let you walk around once you're through.Types are sets.
&shrinks the set (bigger objects),|grows it (less you can do).unknownis everything,neveris nothing,anyis a surrender.Derive, never duplicate.
Pick,Omit,typeof,z.infer— one source of truth, everything else follows from it.Make illegal states unrepresentable. Discriminated unions and optional-
neverbeat a bag of optional booleans, every time.Validate at the boundary, trust inside it.
unknownin, Zod parse, typed value out.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.



