Keentune

TypeScript curriculum

20 chapters
·
313 concepts
·
free
Everything the adaptive question bank can teach and test in TypeScript, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the TypeScript guide
A free 15-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. Type-system foundations
Every annotation is checked and then deleted, so nothing you write in the type layer exists while the program runs. That single fact answers most beginner confusion: "how do I check the type at runtime" always resolves to ordinary JavaScript — typeof, a property check, or a tag you put there yourself.
Almost every beginner question resolves to this line. "How do I check the type at runtime" has no TypeScript answer, because by then the types are gone — you check with ordinary JavaScript, or you keep a tag you put there yourself.
An annotation is checked and then deleted, so it constrains what you may write and nothing about what runs. Its real job is being a claim the compiler will hold you to — which is why annotating a boundary is valuable and annotating an obvious initializer is noise.
Most annotations are unnecessary, and inference is usually more precise than what people write by hand. Annotate the boundaries — parameters, exported signatures, empty containers — and let inference carry the rest. Over-annotating is how a codebase ends up with types that drift from the values they describe.
Lowercase names, always: string not String. The capitalised ones are the wrapper interfaces, they accept almost anything, and using them is the single most common annotation mistake in a first TypeScript file.
Means "not a primitive", which is much wider than the specific shape people reach for it to express. It is useful when you genuinely mean any non-primitive — and a mistake when you meant a record, where Record<string, unknown> says what you actually intended.
Reads like "an object with no properties" and means very nearly the opposite: any value that is not null or undefined. A number satisfies it. So does a string. Annotating a parameter {} to mean "some object" turns the check off almost entirely.
const a: {} = 42; // ok const b: {} = "str"; // ok const d: {} = null; // TS2322 — the only thing it rejects
The capitalised wrapper interfaces match nearly everything, so annotating String instead of string quietly switches the check off. It is the most common first-file mistake, and the error it prevents never appears — the code simply stops being validated.
T[] and Array<T> are the same type written two ways, so the choice is style. It stops being style at readonly: readonly T[] works, readonly Array<T> does not — you need ReadonlyArray<T> instead.
An array where position carries meaning, which is why the compiler tracks length and per-slot types. Reaching for one to model a record is the usual mistake — you get positional access with no names, and every reader has to remember what index 2 was.
Names in a tuple are documentation the editor shows and nothing checks, so they cost nothing and help at every call site. `[x: number, y: number] reads at a glance where [number, number]` needs a comment.
An optional slot changes the LENGTH, not just the value, so a tuple with one is a union of arities. Push past the declared maximum and the error names the count — "3 elements but target allows only 2" — which is the clearest signal that a tuple is not an array.
A fixed prefix followed by any number of trailing elements, which is how a function signature with leading required arguments and a rest gets typed exactly. The rest may sit in the middle too, so long as only one element is variadic.
Fixes both the length and the contents, so a write is TS2540 rather than a silent success. That makes it the right type for a coordinate pair or an RGB triple handed around a codebase — the shape cannot be edited by a helper that receives it.
const e: readonly [number, number] = [1, 2]; e[0] = 9; // TS2540: read-only property
Only members common to every branch are reachable before narrowing, which is the point rather than an obstacle: the compiler is refusing access it cannot justify. Reaching for a cast at that moment throws away the exact information the union was carrying.
Combines members rather than choosing between them, and conflicting primitives produce never rather than an error — so a mistake shows up later as an impossible-to-satisfy parameter rather than at the declaration.
A type admitting exactly one value is what makes a union of them a closed set. The friction is widening: a literal in a mutable position broadens to its base type, so the type you wanted often needs `as const` to survive.
The lightweight alternative to an enum: a closed set of values with no runtime object and no reverse mapping. Everything an enum gives you except a single name that is both a value and a type.
Can name anything — a union, a conditional, a primitive — where an interface can only describe an object shape. The trade is declaration merging: an alias cannot be reopened, which is a feature when you want a closed definition and a limitation when augmenting a library.
Reopenable by a later declaration with the same name, which is how library augmentation works and how a duplicate name silently widens a type instead of erroring. Prefer an alias when you want that door shut.
B. Special & top/bottom types
Not "a type I have not decided yet" — it switches checking off for that value and everything reached through it, with no marker at the call site. One any returned from a helper deletes safety across every caller, silently.
function parse(s: string): any { return JSON.parse(s); } const n: number = parse("{}").deeply.nested; // no error n.toFixed(2); // no error
unknown is the honest version: accepts anything, permits nothing until you narrow.
The gap is silent by construction: no annotation, no inference source, so the parameter becomes any and everything downstream stops being checked. noImplicitAny does not create those holes, it reveals the ones already there.
Accepts anything and permits nothing until you narrow, which is exactly what any should have been. Swapping one for the other is the single highest-value change in most codebases: everything that was silently unchecked becomes a compile error at the point where the check was missing.
Vanishes from a union and is assignable to everything, which sounds like a curiosity until it becomes the exhaustiveness tool: a variable the compiler has narrowed to never is one it has proved cannot occur. Seeing it where you expected a real type usually means a branch is already unreachable.
type A = string | never; // string — never drops out declare const n: never; const b: number = n; // ok — assignable to anything
Means the return is ignored, not that there is none — which is why a callback typed to return void happily accepts a function returning something. arr.forEach(x => set.add(x)) type-checks for that reason, and it is intentional rather than an oversight.
The asymmetry is the whole thing: a callback declared to return void accepts a function returning anything, while a void VALUE is assignable to nothing. One direction is deliberately loose so forEach(x => set.add(x)) works; the other stays strict.
declare function each(cb: () => void): void; each(() => 1); // fine — the return is ignored declare function ret(): void; const v: number = ret(); // TS2322
Under strictNullChecks they stop being members of every type and become things you must narrow away. That is the flag that turns "cannot read property of undefined" from a runtime discovery into a compile error at the place the check was missing.
Strips null and undefined from a union, which is useful in a mapped type or a constraint. On a value you almost always want narrowing instead — the utility changes the type, a check changes the type AND proves it.
PropertyKey is the built-in union of string | number | symbol — the things JavaScript can actually key an object by. Constraining a generic to it says "any legal key" without re-writing the union each time.
Two symbols with the same description are still different keys, and the type system tracks that identity — which is what makes a symbol a genuinely private property name rather than a naming convention.
const K1: unique symbol = Symbol("k"); const K2: unique symbol = Symbol("k"); type T = { [K1]: number }; const bad: T = { [K2]: 1 }; // TS2353: not the same key
C. Assignability & compatibility
Compatibility is decided by the members a type has, not by the name it was declared under, so an object that happens to fit an interface satisfies it without ever mentioning it. That is what makes TypeScript feel light over existing JavaScript, and it is why two unrelated types with the same shape are interchangeable whether you meant them to be or not.
A type with more members is a subtype of one with fewer, which is the opposite of what "bigger" suggests. More members means more requirements met, so it fits everywhere the smaller one does.
The source must supply at least what the target needs, with compatible types — extra members are fine, missing ones are not. Almost every confusing assignability error resolves to reading it in that direction rather than as equality.
A literal written inline is FRESH and gets excess-property checking; the same value through a variable is not. That is why extracting an object to a const makes a typo stop being an error — the check was attached to the literal, not to the type.
A literal written inline is checked more strictly than the same value reached through a variable. That extra check is what catches a typo in an options object — and why assigning through a variable quietly does not.
type Opts = { url: string; retries?: number }; declare function get(o: Opts): void; const loose = { url: "/a", retires: 3 }; get(loose); // accepted get({ url: "/a", retires: 3 }); // rejected
The check exists to catch an options object built against a renamed or misremembered API. Nothing in an all-optional shape is required, so without it any value at all would satisfy the parameter and the typo would sail through. Sharing at least one key is the minimum evidence that the caller meant THIS type.
A literal in a mutable position broadens to its base type, because the binding could hold anything of that type later. let x = "a" is string, not "a". Const keeps the literal, and so does as const on an object or array. This is why a config object handed to a function expecting "get" | "post" fails until you freeze it.
A literal in a mutable position widens to its base type because the binding could hold anything of that type later. Understanding it as a consequence of mutability, rather than as an arbitrary rule, is what makes as const feel obvious instead of magic.
A const, or an as const assertion, keeps the literal because the binding cannot change. Everything about widening follows from whether the value could be reassigned — there is no separate rule to remember.
Type information flows from the position an expression sits in, not only from its own contents. It is why an inline callback needs no annotations while the same function pulled out to a const suddenly does — extracting it removed the context that was typing it.
D. Declarations & inference
A let widens because it could be reassigned, which is the same rule as everywhere else in the language. It only becomes visible when a call wanted a literal — and the fix is as const or a const, not an annotation.
A const keeps the literal type because the binding can never hold anything else; a let widens for the same reason in reverse. That is why moving a value from const to let can break a call that wanted a specific literal, with the error at the call rather than at the change.
const c = "a"; // type "a" let l = "a"; // type string const ok: "a" = c; // fine const no: "a" = l; // TS2322
Freezes an expression to its narrowest literal form and marks every member readonly. The usual reason to reach for it is a config object or array being passed somewhere that expects specific literals, where plain inference has already widened them to string and the call no longer fits.
Infer from several values and the compiler looks for a type they all fit — usually their union, but not always the one you wanted. An array of two different object shapes is the common surprise: you get a union, and every read then needs narrowing.
More accurate than a hand-written annotation, and that is the argument against annotating internals. The argument FOR annotating an export is different: it pins the public shape so an internal change cannot widen it silently, and it puts the error at the function rather than at every caller.
A parameter is typed by the position the function sits in, so the same function needs annotations in one place and not another. Nothing about the function changed — only whether there is a contextual type to read from.
Type arguments are solved from the values actually passed, not from the declaration, so what a generic infers depends entirely on the call. The same function can produce a literal type at one call site and a widened one at another.
When a parameter appears more than once, the compiler must reconcile what each occurrence suggests — usually by taking the best common type. A surprising union in the result is normally two candidates being merged rather than one being wrong.
Information flows inward from arguments and outward from context, and when the two disagree the order decides. It is why annotating a variable can change what a generic infers on the right-hand side — the annotation became the context.
Annotate where a mistake would otherwise travel: parameters, exported signatures, empty containers. Everywhere else inference is more accurate than what people write, and an annotation is one more thing that can drift from the value it describes.
E. Object types
The default, which is the right default: opting out with ? should be a decision. A shape where everything is optional accepts almost any object, which is why TypeScript treats that case specially rather than letting it silently match.
Reading one can yield undefined, so the type forces the check the runtime would otherwise skip. The subtlety is that ? allows the key to be ABSENT, which is not the same as present-and-undefined — and only one flag makes the type care.
An absent key and a key explicitly set to undefined read the same through the type, and differently through Object.keys, spread and JSON. Serialisation is where the difference bites.
type U = { a?: number }; Object.keys({} as U).length; // 0 Object.keys({ a: undefined } as U).length; // 1
exactOptionalPropertyTypes is the flag that makes the type care.
Blocks assignment through that reference and nothing more: another reference to the same object, or plain JavaScript, writes freely. It documents intent and catches honest mistakes rather than enforcing immutability.
readonly stops reassignment of the property itself and nothing deeper, so a readonly field holding an object is a mutable object behind a locked door. Deep immutability needs a recursive type, and runtime immutability needs Object.freeze — which is also shallow.
Describes keys you cannot enumerate — and the cost is that every read is unchecked, since any key now type-checks. Every named property must also satisfy the signature, so adding one forces the others into line. Reach for Record when the keys ARE known; an index signature is for when they genuinely are not.
Once declared, every named property must also satisfy it — so adding [k: string]: number to a type with a name: string is an error on the EXISTING property, not on the signature. That surprise is the usual first encounter.
JavaScript stringifies every key, so a number index cannot contradict the string one — its value type must be assignable to it. The pair models arrays and array-likes; using it on a plain record is usually a sign that a Map was wanted.
Symbol keys get their own index signature, kept separate from string and number. That separation is what lets a type allow arbitrary symbol metadata without loosening its named properties.
Adding an index signature retroactively constrains every named property already on the type, so the error lands on code you did not touch. It is the rule that makes [k: string]: number incompatible with a name: string field sitting beside it.
The way to type a function that also carries properties — a counter with a reset, a middleware with a name. Arrow syntax cannot express it, so the type has to be written as an object.
Types the thing you call with new, not the instance it produces. It is what a factory parameter needs, and it is why InstanceType exists as a separate step to reach the result.
Callable, constructable and property-bearing at once — a shape that exists mainly in older library typings, where a module export was all three. Writing a new one usually means the API is doing too much.
Inherits members and checks the extension for conflicts, so a subtype that narrows a property is fine and one that contradicts it is an error. That check is the difference from an intersection, which silently produces never instead.
Extending several interfaces reports a conflict where an intersection would silently produce never. That difference is the reason to prefer extends when the pieces are object shapes: the error lands where you wrote it rather than at some later property nobody can satisfy.
interface A { x: number } interface B { x: string } interface C extends A, B {} // TS2320: not identical
Two interfaces with the same name in the same scope combine rather than conflict. It is the mechanism behind augmenting a library type, and the reason an interface can be reopened where a type alias cannot. It also means a stray duplicate name silently widens a type instead of erroring, which is the cost of the feature.
Composes object types, and conflicting primitive members produce never rather than an error. The declaration looks fine and the failure appears later at a property nobody can satisfy — which is the difference from interface extends, where the conflict is reported where you wrote it.
A type referring to itself is how trees, linked lists and comment threads get described. Interfaces handle the recursion naturally; an alias can too, so long as the reference sits inside an object or array rather than being the alias itself.
An alias that recurses through arrays and objects is how JSON gets a type at all. The limit is depth: a deeply nested literal can exceed the instantiation budget, and the error mentions recursion rather than your data.
Structural typing means a UserId and an OrderId that are both strings interchange freely. Adding a phantom marker property makes them distinct to the checker while staying plain strings at runtime — the standard workaround for a language with no nominal types.
F. Functions
Inference cannot reach backwards into a parameter — nothing about the body tells the compiler what callers will pass. That is why parameters are the one place annotations are genuinely load-bearing, and why an unannotated one falls back to any.
Usually unnecessary — inference is more accurate than what people write — but valuable on an exported function, where it stops an internal change from silently widening the public type. It also localises the error: without one, a wrong return shows up wherever the value is USED, often in another file.
An optional parameter adds undefined to its own type, so the body must handle the missing case — and it has to come after every required one, since arguments are positional. When the optional thing is in the middle, that constraint is the signal to take an options object.
The default makes the parameter optional for callers while keeping it defined inside, so the body needs no undefined check. Passing undefined explicitly TRIGGERS the default; passing null does not — which is the difference that bites when values come from JSON.
Collects the remaining arguments into a real array, unlike arguments, which is array-like and absent in arrow functions entirely. Typing it as a tuple rather than an array is what lets a wrapper forward its arguments without losing their individual types.
The parameter NAMES in a function type are documentation only; nothing checks them against the implementation. Only the order and the types matter, which is why a callback whose parameters are named backwards still type-checks perfectly and still misbehaves.
Declaring a callback to return void lets callers pass a function that returns something — the return is simply ignored. That is intentional, and it is why arr.forEach(x => set.add(x)) type-checks even though add returns the set.
Pull an inline callback out into a const and it suddenly needs annotations, because extracting it removed the context that was typing it. Nothing about the function changed; only its position did.
Needed whenever a function also carries properties — a counter with a reset, a middleware with a name. The arrow syntax cannot express that, so the type has to be written as an object with a call signature instead.
A new (...) => T signature types the CONSTRUCTOR rather than the instance, which is what a factory parameter needs. It is also the reason InstanceType exists — the two describe opposite ends of the same class.
Several public signatures over one implementation, for calls whose return type depends on the arguments. The implementation signature is not itself callable from outside and must be compatible with every overload. Reach for a union first — overloads are for when the return genuinely varies with the input, not merely when the input does.
Only these are visible to callers, and they are tried in order, so ordering is behaviour. The implementation signature is invisible from outside, which is why a call that "obviously" matches the body can still be rejected.
It is not callable from outside, and it is not checked against the overloads as strictly as people assume — a compatible-looking body can still return something no overload promised. Treat the overloads as the contract and the implementation as the thing that must satisfy all of them, by hand.
Resolution takes the FIRST signature that matches, not the best one, so ordering is behaviour rather than style. Put a more general overload above a specific one and the specific one becomes unreachable, with no warning that it will never be chosen.
Overloads are for when the RETURN type depends on the argument. If it does not, a union parameter says the same thing with one signature and no ordering hazard — and the implementation signature stops being a place where a mismatch can hide.
The value is the link between argument and return. If a parameter appears once in the signature, it is doing no linking and the function would say the same thing with the constraint written inline — the quickest test for a generic that is decoration.
A type parameter can carry a relationship through a callback, so the value handed in and the value returned stay connected. Without it the callback receives the constraint and every user of the result has to cast it back.
A first parameter literally named this types the receiver and is erased at emit, so callers never pass it. It is how you type a callback that will be invoked with a particular this, which is otherwise invisible to the checker.
Declaring this: void says the callback does not use this, which stops a caller passing a method that quietly depends on its receiver. It is a compile-time guard against the classic detached-method bug.
Returning this rather than the class name is what makes a fluent chain survive subclassing: the subclass keeps its own type through every inherited call. Annotate the base as returning the base class and the chain silently downcasts at the first inherited method.
class Box { add(n: number): this { return this; } } class Big extends Box { twice(): this { return this; } } new Big().add(1).twice(); // still Big
Destructuring in the parameter list reads well and costs one thing: the whole object has no name inside the body, so you cannot pass it on without rebuilding it. Keep the parameter when you need to forward it.
The annotation goes on the whole PATTERN. Write it inside and you have renamed the binding instead of typing it — { x: number } makes a variable called number, silently, and the function still compiles.
function g({ x: number }: { x: number }) { return number; } // `number` is now a BINDING NAME, not a type
Accepting readonly T[] widens who can call you: a plain array fits, and so does an as const tuple, which a mutable parameter rejects outright. It also promises you will not write to it, which is the half the caller cares about.
declare function sum(xs: readonly number[]): number; sum([1, 2] as const); // ok declare function mut(xs: number[]): number; mut([1, 2] as const); // TS2345: readonly vs mutable
Spreading a tuple type into a parameter list is what lets a wrapper forward arguments with arity, order and optionality intact. Without it the wrapper degrades to an array and every call site loses its per-argument checking.
Spreading into a fixed-arity call needs a TUPLE, because an array has no known length to check against the parameters. The error names it directly, and as const on the array is usually the whole fix.
declare function pt(x: number, y: number): void; pt(...([1, 2] as const)); // ok const arr: number[] = [1, 2]; pt(...arr); // TS2556: needs a tuple type
G. Narrowing & control-flow analysis
The checker re-derives what a value can be at every point in the flow, which is why a type shown on hover changes as you move down the function. Anything it cannot follow — a callback, an unproven mutation — resets that reasoning to the declared type.
typeof null is "object", a JavaScript wart TypeScript faithfully models — so a typeof x === "object" branch still contains null unless you exclude it first. Functions are the other special case: they report "function", not "object".
An if (x) keeps only truthy values, so it discards 0, "" and NaN alongside null and undefined. On a number | undefined that is rarely what you meant: 0 is a legal value, and it takes the same branch as "missing".
function f(n: number | undefined) { if (n) return n.toFixed(2); // 0 falls through... return "none"; // ...to here }
Test for the absence you actually mean: n !== undefined.
Comparing two unions narrows BOTH sides to their overlap, which is more than most people expect from an ===. Where the overlap is empty the comparison itself becomes an error — the compiler telling you the branch could never run.
Checking for a property selects the union members that declare it, which is the tool for unions that have no shared tag to switch on. It reasons about the DECLARED type, not the runtime object, so a member that merely happens to carry the property at runtime is not selected.
Works through the prototype chain, so it needs a class or constructor function and cannot narrow a plain object shape. It also fails across realms — a value from another frame or a different copy of a library has a different prototype, so the check quietly returns false.
Assigning narrows from that point forward, but never past the DECLARED type — writing a string into a string | number gives you string below, not a permanently narrower variable. The declaration remains the ceiling no matter what flows through it.
Code after a return or throw cannot run, so an early return narrows everything below it. That is why guard clauses read better than nested ifs here: each one permanently removes a case from the rest of the function.
Give every member a property whose type is a distinct literal, and one check on that property tells the compiler exactly which member it has. The tag has to be a literal type on all of them: widen it to string on one member and the union stops discriminating everywhere. It is the pattern that makes unions practical rather than a wall of casts.
Widen the tag on one member — to string, or by dropping as const — and the union stops discriminating EVERYWHERE, not just on that member. The failure surfaces at every switch over the union at once, which makes it look far larger than the one-line cause.
A switch over every member leaves the default unreachable, which is only useful if something enforces it. Without a never assignment there, adding a union member compiles fine and falls through to a default that was never meant to handle it.
Assign whatever reaches the default branch to never. While the switch really is exhaustive nothing can get there, so the assignment is fine — and the day someone adds a union member it stops compiling, at the switch rather than at runtime.
type Msg = { kind: "ping" } | { kind: "pong" }; function reply(m: Msg): string { switch (m.kind) { case "ping": return "pong"; case "pong": return "ping"; default: { const un: never = m; return un; } } }
A function returning boolean tells the compiler nothing about what the answer means. Declaring the return as arg is T connects the two, so a true result narrows at the call site. The compiler does NOT verify the body actually checks for T — a wrong predicate is a lie it will believe, which is why these belong next to their type, tested.
The value is T form is what connects a boolean result to a meaning. The parameter named in it must be one of the function parameters, which is the constraint people hit when trying to narrow a property instead of an argument.
Throws unless a condition holds, and its return annotation tells the compiler to treat everything after the call as narrowed. Unlike a predicate it narrows for the REST of the scope rather than inside a branch — and, like a predicate, the compiler never verifies the body.
Narrows for the REST of the enclosing scope rather than inside a branch, which is why an assertion at the top of a function replaces a wrapping if. The compiler takes the annotation on faith, so a wrong assertion function is a lie it will not question.
The form that narrows to a specific type rather than merely excluding null. It has an awkward requirement: the function must have an explicit type annotation at its declaration, so an arrow assigned to a const needs one written out.
A check stored in a const still narrows later, which is what makes guard variables readable rather than a lost cause. It only holds while nothing can be reassigned — a let breaks it, because the compiler can no longer prove the check still describes the value.
Checking a destructured discriminant narrows the bindings that came with it, which is what makes destructuring a tagged union usable at all. Destructure in two separate statements and the connection is lost.
Narrowing refines the VALUE, not the type parameter, so the generic stays as wide as declared inside the function. Code expecting T to become narrower after a check is the usual disappointment here.
A path like a.b.c narrows as a unit, and any write anywhere along it discards that. It is why a narrowed property widens again after an innocuous-looking assignment several lines away — the compiler cannot prove the path still holds.
Any mutation the checker cannot rule out discards what it had proved, so a narrowing can evaporate because of an assignment several lines away. The variable did not change type — the compiler simply stopped being able to prove the old claim.
A narrowing survives into a callback only where the checker can prove the value cannot change first. A mutable property cannot be proved, so it widens back inside the callback even with the check right above.
function f(o: { s: string | null }) { if (o.s) [1].forEach(() => o.s.trim()); } // "o.s" is possibly "null"
Copy it into a local const first, then close over that.
!== null removes null and leaves undefined in the union. != null is loose equality, which matches both, so one check clears the pair. The difference stays invisible until a value is optional AND nullable.
function f(s: string | null | undefined) { if (s !== null) s.trim(); // possibly undefined if (s != null) s.trim(); // ok }
a?.b yields undefined when a is null OR undefined, so a check on the result cannot tell those apart. Where the distinction matters — absent versus explicitly null — the chain has already thrown the information away.
H. Assertions & operators
Changes what the compiler believes and nothing about what runs, so a wrong assertion produces a runtime error at a line the types called safe. It is a claim you are making, not a conversion — which is why the compiler only allows it between types that plausibly overlap.
The older <T>v form is unusable in .tsx, where the angle bracket begins a tag. That single collision is why as won: one syntax that works in every file.
Going through unknown is how you tell the compiler to stop objecting to an assertion it considers impossible. That objection was information — it means the two types genuinely do not overlap — so the double form is worth treating as a smell that deserves a comment explaining why it is safe.
A claim the compiler cannot check and will not re-examine. Where the invariant is real it is fine; where it has quietly stopped being true, you get a runtime error at a line the types said was safe. Prefer a check that narrows, and reserve ! for cases the type system genuinely cannot express.
Tells the compiler a variable really is assigned before use, somewhere it cannot see. It buys silence rather than safety — if the promise is ever broken, the failure lands at a line the types called fine.
An annotation replaces the inferred type; satisfies checks against it and keeps what inference found. On a lookup table that is the whole difference: annotate and you gain an index signature, so every key typos silently.
const a: Record<string, string> = { home: "/" }; a.hom; // no error: any key is allowed const b = { home: "/" } satisfies Record<string, string>; b.hom; // error: not a key of b
They solve different halves of the same problem. as const narrows but checks nothing, so a typo in a value survives. satisfies checks against a type but does not narrow further than inference already did. Wanting both — validated AND literal — means writing both.
The runtime operator, with a small fixed set of results and two famous wrinkles: null reports "object" and functions report "function". It shares a keyword with the type-level operator and has nothing else in common with it.
The type-level operator reads the type of an existing VALUE, which is how a config object becomes a type without being described twice. It shares a keyword with the runtime operator and does something unrelated.
Produces the union of a type keys, which is what keeps every key-based helper honest. On a type with an index signature it gives `string | number` rather than the named keys, which is the surprise that sends people looking for a bug in their generic.
I. Generics
The point is the RELATIONSHIP: a parameter ties an argument to a return so both move together. A signature that mentions its parameter only once has gained nothing over the constraint it is bound by, which is the quickest test for a generic that is not earning its keep.
Unlike a generic interface, an alias can parameterise anything — a union, a conditional, a template literal, not just an object shape. That is most of the reason the type-level utilities in this chapter are aliases rather than interfaces.
Parameterises an object shape, and unlike a generic alias it can be reopened by declaration merging. That makes it the right choice for a type consumers may need to augment, and the wrong one for anything that is not an object.
The parameter is fixed at construction and shared by every member, so a static cannot see it — statics belong to the class, not to any instantiation. That is the constraint that surprises people writing a static factory on a generic class.
Without one, the body can do nothing with the value — an unconstrained parameter is effectively unknown inside the function. The constraint is what unlocks members, and every member it adds is a caller it turns away, so it should match exactly what the body touches.
A constraint does two jobs at once: it limits what callers may pass, and it tells the body what members it may rely on. Without one, a type parameter is effectively unknown inside the function. Constrain to what the body actually uses — no more.
Ties a key argument to the object it indexes, so T[K] is guaranteed to be a real property type rather than a guess. It is what makes a generic get(obj, key) return the right type per key instead of a union of every value type.
Let independent parts of a signature vary independently. If two parameters always move together, they were one parameter — and if one is never referenced twice, it was not doing any work.
Constraining one parameter by another is how a signature says two arguments must line up — a key and its object, a getter and its field. It is the difference between a generic that documents a relationship and one that merely accepts two things.
Lets callers omit an argument while keeping the parameter available for those who want it — the pattern behind most library types that "just work" until you need to customise them. The default must satisfy the constraint, which is easy to forget when adding one later.
Supplying them yourself overrides inference, which is what you want when inference widens too far or has nothing to work from. It is also all-or-nothing per call in most cases: name one and you name them all.
The compiler solves type parameters from the values you actually pass, which is why explicit arguments are usually unnecessary. Supply them by hand when inference widens further than you want, or when there is nothing in the arguments to infer from — a common case with a function that only returns.
A factory typed over its input returns the specific thing it was handed rather than a base type. Without the parameter every call site gets the constraint back and has to cast — which is the smell that a generic was needed.
Constrain to new (...) => T when the function must CONSTRUCT rather than call. It also excludes abstract classes, which have no construct signature — the case that sends people to abstract new (...).
Carrying a parameter through a function that takes or returns another function is what makes a wrapper transparent. Lose it and every consumer of the wrapped function gets the constraint back instead of their own type.
Threading a parameter through each layer keeps the outermost caller type. Break the chain anywhere — one intermediate typed to the constraint — and everything downstream collapses to it, no matter how careful the layers below were.
A parameter standing for a whole argument list is how forwarding stays exact. It is the machinery behind Parameters, and the reason a well-typed wrapper needs no overloads.
A tagged union whose payload varies by parameter keeps tag and data in step, so a handler for one case cannot receive another payload. It is the pattern behind typed events and typed actions.
The constraint is the widest thing allowed; the argument is what the caller actually chose. Confusing them is what produces a function that returns the constraint instead of the caller type — technically valid, and useless at every call site.
Every member you add to a constraint is a caller you turn away. If the body only reads .length, constrain to { length: number } rather than to an array, and both arrays and strings keep working. The bug this prevents is invisible: nobody reports the call they never wrote because the signature refused them.
J. Type manipulation
Writing T["id"] instead of repeating string means the day id becomes a branded type, every place that read it follows automatically. It is the difference between a type that describes the shape once and one that describes it in six places that can drift apart.
Index by a union of keys and you get a union of their value types, in one step. T[keyof T] is the idiom that falls out: every value type in the object, which is how you constrain a lookup to whatever the object actually holds.
T[number] reaches the element type through the numeric index, and it works on tuples too, giving the union of every position. Combined with as const it is the standard way to turn a literal array into the union of its members.
Indexing a tuple by a literal position gives that slot alone, while indexing by number gives the union of all of them. Reaching for the second when you meant the first is how a precise tuple type degrades to a union nobody can narrow.
Lets a runtime object stay the single source of truth: define the object, then derive the union of its keys instead of writing that union a second time. Add a key and the type follows; write the union by hand and the two drift apart the first time someone edits only one.
The branch is chosen by ASSIGNABILITY, not equality, so "a" extends string is true and the true branch wins. Reading it as an equality test is the source of most surprise: a narrower type always satisfies a wider one.
A constraint inside the conditional narrows what the true branch may assume, which is what lets a recursive utility stay type-safe as it descends. Without one, the branch has to re-test everything it already proved by getting there.
The captured type exists only in the true branch, so a conditional that needs it in both has to nest. That constraint is why so many utility types are written as a chain of conditionals rather than one.
Names a piece of the type being matched so the true branch can return it. The capture exists only inside that branch — referencing it in the false branch is an error, which is the first thing everyone tries.
Matching a function type and capturing its return in one step is what ReturnType does, and writing it yourself is the clearest way to see infer working. The capture only exists inside the true branch, which is why the false branch has to supply something else.
Capturing a parameter list as a tuple is what makes a wrapper forward arguments exactly — arity, names and optionality preserved. Collapse it to an array instead and every call site loses its per-argument checking.
Unwrapping one level is easy; the reason Awaited is recursive is that a promise of a promise is a real shape, and stopping at the first level leaves you with a type nobody can await away.
Chained conditionals read like a type-level switch, and like a switch the ORDER decides the outcome: the first matching branch wins, so a broad test placed early makes every later one unreachable.
Lets a type walk a nested structure — flattening arrays, deep-readonly, unwrapping promises. The recursion has a depth limit, and hitting it reports as "type instantiation is excessively deep" rather than as anything about your structure, which makes it a confusing first encounter.
When the checked type is a bare type parameter, the conditional runs once per union member and the results are unioned back. That is usually what you want, and occasionally a surprise. Wrap both sides in a tuple to switch it off and test the union as one thing.
type NoNull<T> = T extends null ? never : T; type A = NoNull<string | null>; // string type Boxed<T> = [T] extends [null] ? never : T; type B = Boxed<string | null>; // string | null
Wrapping both sides in a one-element tuple is the standard trick, and it exists because the default is surprising rather than wrong: a naked parameter distributes. [T] extends [U] asks the question about the union as a whole instead.
A mapped type built directly over keyof T is homomorphic, which means it carries readonly and ? across automatically. Rewrite it to map over an unrelated key union and that property quietly disappears — the modifiers stop being preserved and every optional field becomes required.
Mapping over keyof T directly is what makes a mapped type homomorphic, which is the property that preserves modifiers and keeps the relationship to the source. Introduce a different key union and you get a new type that happens to share names — and loses readonly and ? on the way.
A mapped type can add or strip readonly and ? as it copies, so the output need not inherit the input decisions. Forgetting that a plain mapped type PRESERVES them is the usual surprise: your "copy" is still readonly because the source was.
-readonly is how you write a mutable copy of a frozen type, and it is shallow like everything else here — the nested objects stay exactly as readonly as they were.
-? removes optionality and +? adds it, which is all Required and Partial are. Removing it is the sharper edge: a type where every field must now be present is often one no existing caller can build.
type Opt = { a?: number }; type Req = { [K in keyof Opt]-?: Opt[K] }; const bad: Req = {}; // TS2741: a is now required
Renaming keys while mapping is what makes generated shapes possible — getters from fields, event names from actions. Remap a key to never and it drops out entirely, which is how filtering is expressed with no separate filter step.
Remapping a key to never removes it, so filtering needs no separate step — the condition lives in the as clause. Picking every method off a type is the standard use, and the removed keys are gone for real: an object literal supplying one is TS2353.
type Fns = { a: () => void; b: number }; type Only = { [K in keyof Fns as Fns[K] extends Function ? K : never]: Fns[K] }; const bad: Only = { a: () => {}, b: 1 }; // TS2353
Builds string literal types from other types, which is what lets a name be derived rather than repeated. The moment an interpolated piece is plain string, the whole result collapses to string and the precision is gone.
Interpolating a union produces every combination, so two unions of four give sixteen members and three give sixty-four. It is the fastest way to generate a class-name or event-name type — and the fastest way to build one too large to be useful.
type Size = "sm" | "lg"; type Side = "top" | "left"; type Cls = `${Size}-${Side}`; // 4 members const bad: Cls = "md-top"; // TS2820, and it lists them
Matching a string literal against a pattern and capturing part of it with infer is how a route type extracts its parameters, or a getter name yields its field. The match is on the TYPE, so it only works when the string is a literal type rather than plain string.
The four casing helpers are implemented inside the compiler rather than in type syntax, which is why you cannot write your own. They exist because key remapping needs them — get${Capitalize<K>} is the pattern they were added for.
One of four casing helpers implemented inside the compiler. They exist for key remapping — building SET_VALUE from value, or a screaming constant from a field name — rather than for general string work.
The inverse of Uppercase, and equally intended for key generation. Both leave a plain string untouched: with no literal to transform, the result is just string again.
Raises only the first character, which is exactly what a getter name needs: get${Capitalize<K>}. That single use case is why it was added alongside key remapping.
Lowers the first character — the inverse of Capitalize, and the piece you need to go the other way, deriving a field name from a method name rather than the reverse.
K. Built-in utility types
Makes every property optional, built from the -? mapped modifier. The trap is reaching for it on an update function: Partial<T> also allows the empty object, so a caller who forgets every field type-checks perfectly and updates nothing.
Strips ?, and under exactOptionalPropertyTypes that is a genuine change rather than a cosmetic one: a property that could be absent becomes one that must be present. Applying it to a type full of genuinely optional fields usually produces something no caller can construct.
Shallow, and the depth is where people are caught out: the top-level properties are protected while everything reached through them is not. A frozen-looking config whose nested object is still mutable is the usual way this bites.
type Cfg = { nested: { n: number } }; const r: Readonly<Cfg> = { nested: { n: 1 } }; r.nested.n = 2; // allowed: only the top level is frozen
Keeps the named keys, and unlike Omit it CHECKS that they exist, so a typo is an error rather than a silently empty type. That difference is the reason to prefer Pick when you are naming a handful of fields and Omit only when you are removing a couple from many.
Removes named keys, and — unlike Exclude — it does NOT check that they exist. Omit<User, "emial"> is a silent no-op that keeps every field, so the shape looks right and nothing warns. Worth knowing before you trust one in a public signature.
The key argument matters more than it looks: Record<string, T> gives an index signature where every key type-checks, while a union of literals gives a closed set where a typo is an error. Reaching for the first when you meant the second is how a lookup table stops catching anything.
Works on UNION MEMBERS, not on object keys, which is the confusion to settle early: Exclude filters "a" | "b" | "c", Omit strips keys from an object type. It keeps members NOT assignable to the second argument, so it removes anything the filter would accept, not just exact matches.
Keeps the members assignable to the filter, which means it matches more than exact equality — filtering a union of literals by string keeps every string literal in it. That is usually what you want, and occasionally the reason a filter returns more than expected.
Returns the parameter list as a TUPLE, which is what makes forwarding exact — spread it into a wrapper and the arity and types are preserved rather than collapsed to an array. On an overloaded function it picks the LAST overload, which is rarely what you wanted.
Returns a constructor argument list as a tuple, so a factory can accept exactly what the class does. Pair it with InstanceType and a wrapper can construct anything without knowing what.
Reads what a function type returns, and on an overloaded function it takes the LAST overload rather than resolving anything. Pair it with Awaited for an async function, or you get the promise rather than the value.
Gives what a constructor produces, which is what lets a factory typed over a class return real instances rather than a base type. It needs a construct signature, so it fails on an abstract class — the case that sends people to abstract new (...) instead.
Extracts the declared this parameter, which only exists if someone wrote one. On an ordinary function it gives unknown — not an error, which is why a helper built on it can silently do nothing.
Strips the this parameter back off, producing the type you get after binding. It is what lets a bound method be assigned somewhere that expects a plain function.
Sets what this means inside an object literal, with no runtime effect at all. It is the mechanism behind options objects whose methods can see their siblings — the Vue-style API shape.
Unwraps a promise recursively, the way await does, so a nested promise resolves to its innermost value rather than to another promise. It is what makes ReturnType usable on an async function without leaving you holding a Promise<T> you then have to unwrap by hand.
Stops one argument from widening the type parameter another argument established. Without it a fallback silently joins the union it was supposed to be checked against — the call compiles and the constraint you thought you had is gone.
declare function f<T>(xs: readonly T[], fb: NoInfer<T>): T; const items = ["a", "b"] as const; f(items, "zzz"); // TS2345: not assignable to "a" | "b" // without NoInfer the same call compiles
They compose, so most bespoke shapes need no hand-written conditional type at all — Partial<Pick<T, K>> and friends cover a lot. Reaching for infer before trying the composition is the usual over-engineering here.
Writing your own means combining mapped types, conditionals and infer. The discipline that keeps them readable is naming the intermediate steps: a chain of three small aliases is far easier to debug than one expression nobody can hover.
L. Compiler configuration
Not one check but a family, and the members are individually controllable — which is what makes a migration tractable: turn strict on, then switch off the one or two that produce most of the errors and burn them down separately. Leaving it off entirely is the choice that costs the most later.
Off, null and undefined belong to every type, so nothing about a value tells you whether it can be missing. On, they become members you must narrow away first. It is the single flag that separates TypeScript from a linter, and turning it on late in a codebase is where most of a strictness migration is actually spent.
Without it, a parameter the compiler cannot infer silently becomes any, and everything reached through it stops being checked. The errors it produces are not new bugs — they are the places that were never being checked in the first place.
The check applies to function-type PROPERTIES and deliberately not to METHODS, which stay bivariant even under strict. So the same handler is rejected in one position and accepted in the other — the remaining unsoundness the flag does not close, kept because tightening it would break most existing object-oriented code.
type Prop = { f: (x: string | number) => void }; type Meth = { f(x: string | number): void }; declare const narrow: (x: string) => void; const a: Prop = { f: narrow }; // TS2322 const b: Meth = { f: narrow }; // accepted
A declared field that no constructor assigns is undefined at runtime while the type says otherwise. The flag closes that, and the escape hatches are deliberate: ? if it really is optional, ! if something outside the constructor assigns it.
Off, xs[0] is typed as if the element must exist — which is a lie for every array that can be empty. On, the read yields T | undefined and you have to say what happens when it is missing.
function first(xs: string[]) { return xs[0].trim(); } // off: compiles. on: "Object is possibly undefined"
Noisy on hot paths, honest everywhere else.
Without it, x?: number quietly means number | undefined, so code may assign undefined explicitly. With it, the property may be ABSENT or a number, and writing undefined is an error. The distinction only matters where absent and present-but-undefined behave differently — which is most serialisation code.
A caught value is not necessarily an Error: any value can be thrown, so e.message was always a guess. Typing it unknown forces the check you should have been making, and instanceof Error is usually the whole fix.
Rename a base method and every subclass that meant to override it goes quietly from overriding to defining something new — same code, no error, wrong behaviour. Requiring override turns that silent break into TS4114 at the subclass.
Catches the missing break, and only where it matters: a case with no statements at all still falls through legally, since stacking labels is a deliberate idiom. It is a case with a BODY and no terminator that is almost always a mistake.
Catches the branch that forgot to return — a real bug that plain strict misses whenever the return type already admits undefined. Isolating it matters: annotate the return as number | undefined and the function compiles without the flag, and reports TS7030 with it.
function f(x: number): number | undefined { if (x > 0) return x; // no return below } // off: compiles. on: TS7030 "Not all code paths return"
Changes the emitted syntax AND the built-in library that comes with it, so lowering it can remove APIs you were relying on. That double effect is why target and lib are separable — you can compile down while still declaring a newer runtime.
Describes what the runtime provides, which is why a Node project that includes dom gets document and window in autocomplete and no error when it uses them. The build passes; the process crashes.
Decides the shape of the emitted imports, which is what actually has to match the loader. Setting it to something the runtime does not accept produces a build that succeeds and a program that fails to start — the commonest ESM-versus-CommonJS symptom.
How a specifier becomes a file, and the most common cause of "it works in my editor but not in the build". nodenext follows Node exactly, including its extension rules; bundler follows what bundlers do, which is looser. Pick the one matching whatever actually loads your code, not whichever produces fewer errors.
Stops the compiler deciding for you which imports to erase, so what you wrote is what is emitted. The trade is that every type-only import must say so — noisier, and unambiguous, which matters most where a module has side effects.
Requires that every file can be transpiled on its own, with no knowledge of the others — which is exactly how bundlers and esbuild work. It rejects the constructs that need whole-program information, const enum being the usual casualty. Turn it on early; retrofitting it is where a migration to a faster toolchain stalls.
Without it a package ships JavaScript that consumers cannot type, which reads to them as a missing @types entry. With it, your public surface is inferred from the code — so a type you never meant to export becomes part of the API the moment it appears in a signature.
Without it, Go-to-Definition in a consuming project lands on the .d.ts and stops. With it, the editor follows through to your real source — the difference between a dependency you can read and one you can only guess at.
Without it a stack trace points at emitted JavaScript, so the line numbers belong to a file nobody wrote. It costs build time and output size, which is why the question is where to ship them, not whether to generate them.
Stores what it learned in .tsbuildinfo so a later run only redoes what changed. The file is a cache: delete it and you get a slow build, not a wrong one — which is the first thing to try when incremental output looks stale.
Turns on the constraints a project must satisfy before others may reference it — declarations, a known root, no stray files. It is not a performance switch on its own; it is the entry fee for project references, which is where the build speed comes from.
Affects the CHECKER only. The emitted import keeps the original specifier, so unless the bundler or runtime is configured to agree, the build passes and the process cannot resolve the module — a failure that appears at start-up rather than at compile.
types is a whitelist: name anything and every other ambient package stops loading. It is the fix for a stray global leaking in, and the cause of a mysterious missing global when someone added an entry without realising it excluded the rest.
Relative paths in the inherited config resolve from the file that DECLARED them, not from the one doing the extending. That single rule is behind most "my base config works everywhere except here" reports.
exclude only filters what include gathered; it cannot remove a file something imports. A file you thought was excluded still gets checked the moment anything reaches it, which is why an exclusion sometimes appears to be ignored.
Skips checking .d.ts files, which is a real speed win and a real blind spot: a broken or conflicting type from a dependency stops being reported. Most projects turn it on because two libraries disagree, not because they wanted less checking.
M. Modules & package architecture
Resolution, loading and interop all differ, and the trouble is almost never inside one system — it is at the boundary, where a default export may or may not be the whole module. Most "cannot use import statement" errors are that boundary being crossed by accident.
States that an import exists for types alone, so it is erased with certainty rather than by inference. That matters when the module has a side effect: elision would drop it silently, and import type makes the intent explicit enough to review.
An import used only in type positions is deleted from the output. That is normally invisible — until the module had a side effect you were relying on, which now never runs. import type states the intent, and verbatimModuleSyntax stops the compiler guessing on your behalf.
Turns everything not listed into a private path, which breaks consumers who were reaching into your internals — deliberately. It also means a missing entry looks to them like the file does not exist, so adding the field to an existing package is a breaking change.
Internal #-prefixed specifiers resolve inside your own package, giving deep paths a stable name without exposing them. Unlike a paths alias this is understood by the RUNTIME, so it survives the build.
Serves a different file per condition, so the same specifier resolves to ESM for an import and CommonJS for a require. Ordering matters: conditions are matched top to bottom, so a broad one placed early masks everything below it.
Carry types with no implementation, which is what lets a JavaScript package be consumed safely. When they drift from the code they describe nothing catches it — the types compile, the runtime disagrees, and the error appears in the consumer.
Describe something that exists at runtime with no source to check — a global from a script tag, an untyped dependency. Nothing verifies the claim, so an ambient declaration is exactly as correct as whoever wrote it, and it fails at runtime rather than at compile.
Declares the shape of a module you do not control, which is what makes an untyped dependency importable. A wildcard form can also type non-code imports — CSS, images — that a bundler resolves.
Adds members to an existing module type from your own file, which is how a plugin teaches the compiler about what it attaches at runtime. It has to target the same module specifier the consumer imports, which is the usual reason an augmentation appears to do nothing.
Adds to the global scope, which every file then sees whether it wanted to or not. That reach is the reason to avoid it where a module augmentation would do: a global added for one file becomes part of everyone else s environment.
The pre-module way to group declarations, kept mainly for ambient typings and legacy code. In new code a module already provides the boundary, so a namespace inside one is nesting with no benefit.
Split a build into projects that compile separately and depend on each other explicitly, so a change rebuilds only what it touched. The entry fee is composite plus declaration output on every referenced project.
A shared base plus per-package overrides keeps settings consistent without duplicating them. The trap is inherited relative paths: they resolve from the file that declared them, so a base config s paths do not mean what a package might assume.
Types, entry points and conditions all have to agree, or consumers resolve the wrong file and the failure appears in THEIR build rather than yours. It is the part of shipping a package that is easiest to get silently wrong.
Ship both ESM and CommonJS and a dependency graph can load both copies, giving two separate module states. Anything relying on identity breaks quietly: instanceof fails, a registry has two halves, a singleton is two singletons. The failure looks like a logic bug rather than a packaging one, which is what makes it expensive.
N. Classes
A field with an initializer takes its type from that value and needs no annotation. One without gets whatever you annotate — and one with neither is an implicit any under a loose config, which is where a class quietly stops being checked.
A promise you make to the compiler when a field really is assigned, but somewhere it cannot see — an init method, a framework, a test harness. It silences the error without adding a check, so if the promise is ever broken you get undefined at a line the types called safe.
Assignable in the constructor and nowhere else — and only at compile time, so nothing stops a cast or plain JavaScript from writing to it. It documents intent and catches honest mistakes; it is not a runtime guarantee, which #private and Object.freeze are.
Several public constructor signatures over one implementation, for a class that can be built from genuinely different inputs. As with function overloads, the implementation signature is invisible to callers and the order of the public ones decides which matches.
Touching this before super() is an error rather than a warning, because the instance does not exist yet — the base constructor is what creates it. Anything you want to compute from constructor arguments first has to be a local, not a field.
A base constructor that calls an overridable method sees the subclass fields as undefined, because field initializers run AFTER super() returns. The method is already overridden, so it runs — just too early to see anything it depends on.
class Base { constructor() { this.hook(); } hook() {} } class Sub extends Base { field = "set"; hook() { console.log(this.field); } // undefined }
Declares the field and assigns it from the constructor argument in one place, which removes the commonest class boilerplate. It is TypeScript-only syntax with no JavaScript equivalent, so it is one of the few features that will not survive a move to plain JS or to a type-stripping runtime.
A pair lets the property normalise on write and stay typed on read, and the two may differ — the setter can accept more than the getter returns. Before that was allowed the pair had to share one type, which pushed the normalising out into a method.
A setter may accept more than its getter returns, which is exactly what you want for a property that normalises: take a string or a Date, hand back a Date. Before this was allowed the pair had to share one type and the normalising had to move out to a method.
Omit the setter and the property is readonly from outside, with no readonly keyword anywhere. It is the cheapest way to expose derived state without letting callers assign to it, and it reads as a plain property rather than a method call.
Generates a getter and setter over hidden storage, so a plain field can gain interception later without changing how callers use it. It exists largely because decorators need something to decorate.
All three vanish at emit, so private is advice to the checker rather than protection. Anything that must actually be unreachable at runtime needs #private, which the engine enforces — the distinction that matters the moment untrusted code shares the object.
Everywhere else compatibility is structural, so two identical shapes interchange freely. A private member breaks that: each declaration is its own identity, so two classes with the same private field are NOT assignable to each other. It is the closest thing TypeScript has to nominal typing, and it arrives by accident rather than by design.
class P { private x = 1; } class Q { private x = 1; } let p: P = new Q(); // error: separate declarations
Unlike private, #x survives compilation and is enforced by the engine, so it cannot be reached by a cast, a bracket access or anything else at runtime. That also makes #x in obj a genuine brand check — the one reliable way to ask whether a value really is your instance.
class Safe { #token = "s"; static has(o: unknown) { return #token in (o as Safe); } } Safe.has(new Safe()); // true Safe.has({}); // false
Callers cannot use new, so construction has to go through whatever static method you provide. That is how a singleton or a factory-only class is expressed — and, like every other private, it is a compile-time rule that vanishes at runtime.
Belong to the class object itself, so they are typed separately from instances and are inherited by subclasses as class properties. A static cannot see instance type parameters, which is the constraint that surprises people writing a generic factory.
Inside a static, this is the class itself, and typing it polymorphically lets a base static return the SUBCLASS when called through one. That is how an inherited factory produces the right type rather than the base.
Cannot be instantiated, which is enforced at compile time only — the emitted class is ordinary and new on it through a cast works fine. The value is the contract for subclasses, not any runtime protection.
A subclass that forgets one fails to compile, which is the whole point: the base declares what must exist without saying how. An abstract member with a body is a contradiction the compiler rejects.
It verifies and inherits nothing, so a class that implements an interface still has to write every member itself — TS2420 if it does not. People reach for it expecting extends behaviour and get a checking clause instead.
interface Greets { greet(): string; } class C implements Greets {} // TS2420: greet is missing
implements accepts any object type, not only an interface — a type alias, an intersection, even a mapped type. It is a conformance check against a shape, so anything that describes a shape will do.
A class is a value, so it can be returned from a function, stored, or built inside a closure — which is what makes mixins possible at all. An anonymous one still has a type; it simply has no name to refer to it by.
Returning this keeps a fluent chain typed as the SUBCLASS through inherited calls. Annotating the base as returning the base class breaks the chain at the first inherited method, silently downcasting.
abstract new (...) => T types a class you may subclass but never instantiate, which an ordinary construct signature cannot express. It is what a mixin or a registry needs when the base is abstract.
Same rules as function overloads, inside a class body: the public signatures are what callers see, the implementation is invisible, and order decides which matches. The extra wrinkle is that overloads do not inherit — a subclass redeclaring one replaces the whole set.
O. Enums
Numbering resumes from the last EXPLICIT value rather than restarting, so inserting a member in the middle silently renumbers everything after it. If those numbers were ever persisted, the stored data now means something else.
enum E { A, B, C = 10, D } // A=0 B=1 C=10 D=11
A string enum emits only the forward direction, so there is no reverse entry to look up. Give a value and you cannot get the member name back without writing that map yourself. The upside is the emitted object is exactly what you declared, with no surprise keys.
Mixing string and numeric members is legal and almost never wise: the numeric half gets a reverse mapping and the string half does not, so iterating produces an inconsistent shape. The feature exists mainly for migration.
A numeric enum compiles to an object mapping BOTH ways, so the numbers are keys too. That is why Object.keys on one returns twice what you expect, and why iterating an enum is a reliable source of surprise.
enum Dir { Up, Down } Dir[0]; // "Up" — the reverse entry Object.keys(Dir).length; // 4, not 2
A computed member has no value the compiler can know, so everything after it must be initialised explicitly — auto-increment has nothing to count from. The error names the following member rather than the computed one, which sends people to the wrong line.
The name lives in both namespaces at once: as a type it is the union of its members, as a value it is the runtime object. That dual nature is why an enum cannot be erased the way an interface can, and why it is the one TypeScript feature that adds code to your bundle.
Members are substituted at each use and the object is never emitted, so the enum leaves no runtime footprint. That is also why it cannot cross a compilation boundary: a separately built consumer has nothing to inline from.
A const enum inlines its members, which requires seeing the whole program — exactly what a single-file transpiler cannot do. That is why isolatedModules rejects it, and why bundler-based toolchains and const enum do not mix.
A literal union plus as const gives the same checking with nothing emitted, no reverse mapping, and no const enum transpiler problem. The trade is losing a single name that is both value and type — usually worth it, which is why most style guides now start here.
P. Iterators, generators and symbols
A value is iterable when it has a [Symbol.iterator] method returning an iterator. Nothing else is required, and nothing about the class or its name matters — which is why a plain object can be made iterable in three lines and why for...of rejects one that has not been.
for...of needs the iterable protocol, so a plain object fails however array-like it looks. That is the moment people reach for for...in and get keys instead of values — the two are not alternatives.
for...in walks enumerable KEYS, including inherited ones, and hands them to you as STRINGS even on an array. for...of walks values via the iterable protocol. Using the first on an array is the classic bug: the index is "0", not 0.
for (const k in ["x", "y"]) typeof k; // "string" for (const v of ["x", "y"]) v; // "x", "y"
Generator<Y, R, N> carries three types: what it yields, what it RETURNS at the end, and what next() accepts back. Most code only ever names the first, which is why the return value of a generator is so often typed any by accident.
Hands control to another iterable and resumes when it is exhausted, so a generator can compose others without a manual loop. It also forwards the delegated generator return value, which a for...of over the same thing would silently discard.
for await...of waits on each value as it arrives, which is how a stream, a paginated API or a queue reads like a loop. The sequence is serial by construction — it awaits each item before requesting the next, so it is the wrong tool when you wanted concurrency.
A unique symbol used as a property key gives a slot nothing else can name by accident. It is how a library attaches metadata to a value without risking a collision with a consumer field.
Symbol() produces a value equal to nothing but itself, ever. Two calls with the same description are still different symbols, which is the whole point. Symbol.for looks up a GLOBAL registry instead, so the same key returns the same symbol across realms — the escape hatch when you actually need sharing.
Q. Decorators
Two incompatible designs share a syntax. The standard ones are the ECMAScript proposal; the legacy ones sit behind experimentalDecorators and are what most existing frameworks were built against. Signatures, arguments and evaluation order all differ, so decorator code found online is often for the other one — check which before adapting it.
It runs once, when the class is defined, not per instance. Anything it does happens at module evaluation — so a decorator that registers the class somewhere runs on import, whether or not the class is ever used.
The second argument tells the decorator what it is decorating — kind, name, whether it is static — and hands it the hooks it needs. It is the clearest difference from the legacy design, which passed a property descriptor instead.
Return a value and it replaces what was decorated; return nothing and the original stands. That is the difference between observing a member and substituting it, and it is easy to do by accident with an arrow function whose body is an expression.
Registers work to run when the class or instance is set up, from inside a decorator. It is how a decorator binds a method or registers an instance without replacing the member it was applied to.
The decorator EXPRESSIONS evaluate top to bottom, then the resulting functions APPLY bottom to top. Two orders in one place, which is why a stack of decorators that reads correctly can still compose backwards.
R. Mixins
A mixin is a function that takes a class and returns a subclass, so it needs a type meaning "something constructible". That alias is what lets the parameter accept any class while the return type still tracks what was passed in.
The base constraint takes (...args: any[]) because a mixin cannot know what its input constructs with, and must pass those arguments through untouched. It is one of the few places any is the honest answer rather than a shortcut.
A mixin returns an anonymous class, which is why the pattern needs a class EXPRESSION rather than a declaration. The returned type is inferred, so the caller keeps everything the mixin added without anyone naming it.
Each mixin wraps the previous class, so composition is nesting and the ORDER decides what overrides what. Two mixins defining the same member do not conflict — the outer one simply wins, silently.
S. JSX
A JSX expression has a type, which is what lets a component be checked like any other value. Where that type comes from depends on the runtime, which is why a React and a Preact project disagree about what a valid child is.
Mistype the case on a component and the error you get is a missing HTML element, not a missing import — because the compiler resolved the tag the other way. Capitalisation alone decides whether a name is looked up among built-in elements or among values in scope, and nothing else about the tag changes that.
Attributes are checked against the props type, so a typo or a missing required prop is an error at the call site. That is most of what JSX typing buys — the component itself was already checked.
Children are not inferred: a component that renders them still has to declare it, or passing any is an error. It is the most common surprise when moving a component from JavaScript.
Chooses the emit, and getting it wrong produces an error about React not being defined in a file that never mentions React. The modern automatic runtime imports what it needs, which is why the old "React must be in scope" rule stopped applying.
A generic arrow that compiled happily stops doing so the moment the file is renamed to .tsx, because the angle bracket now begins a tag. The trailing comma in <T,> is what tells the parser otherwise — an odd-looking fix for a genuine grammar collision.
T. The compiler as a program: CLI, emit and JavaScript interop
The nearest tsconfig.json above a file defines the project it belongs to, which is what editors use to decide the settings for it. A file outside every project gets defaults instead — the usual explanation for a script folder mysteriously not obeying strict.
Name files on the command line and tsconfig.json is skipped entirely — not merged, skipped. So a file that type-checks in your editor can fail from a script, or vice versa, purely because one of them is using the project config and the other is not.
By default tsc writes JavaScript even when type checking failed, on the view that the output is still probably runnable. That surprises people expecting a compiler to refuse — and it means a build that "worked" may have printed errors nobody read.
const n: number = "no"; // TS2322 // tsc still writes x.js unless noEmitOnError is set
Turns a type error into a build failure, which most people assume is already the default. Without it tsc writes JavaScript anyway, so a pipeline that ignores the exit code ships output produced from code that did not check.
Type-checks and writes nothing, which is the right mode when a bundler owns the build. Running tsc for validation while esbuild or SWC produces the output is the standard modern setup, and this is the flag that stops the two fighting over the same directory.
Sets the folder the output tree mirrors, so it decides where files land under outDir. Import something above it and the whole tree shifts down a level to accommodate the new common root — which is why output paths change after an apparently unrelated import.
A file with no imports or exports is a SCRIPT, so its declarations are global and can collide with everything else. force makes every file a module, which is usually what people assumed was happening already.
Two switches, often confused: allowJs lets JavaScript into the program, checkJs makes errors in it visible. Turning on the first alone is how a migration gets JavaScript compiling alongside TypeScript with none of it actually being checked.
Opts a single JavaScript file into checking, with types supplied by JSDoc. It is the incremental route into a large codebase: no build change, no renames, one file at a time — and the same checker, so what it finds is real.
A triple-slash directive is only a directive at the TOP of a file — below any statement it is a comment, silently. Nothing warns, so the reference simply has no effect and the type it was pulling in goes missing.
The with syntax replaced the earlier assert form, which was withdrawn before it settled. Code written against the old spelling still exists in tutorials, so an example that looks current may be using syntax that no longer parses.
A rewrite of the compiler as a native binary, aimed at build speed rather than new language features. The type system it implements is the same one — what changes is how long you wait for the answer.
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about TypeScript practice
Also on your phone
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.
© 2026 SportaApp LLC