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.
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.
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.
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.
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 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.