TypeScript essentials
How type erasure works
Every annotation, interface, and as in a TypeScript file is deleted during compilation. The emitted JavaScript contains zero type information, so nothing is checked at runtime. A parameter typed string receives whatever JS hands it, and from there the runtime type and coercion rules you know from types-coercion take over unchanged.
function greet(name: string) { return "Hi " + name; }
greet(JSON.parse("42")); // compiles if tsc can't see it; runs; returns "Hi 42"
❌ "as string converts the value". x as T changes the compiler's opinion of x, never the value. If the opinion is wrong, a mistyped value flows on wearing the wrong label. Real input (fetch, JSON, forms) needs runtime validation; a type on boundary data is only as true as the check behind it.
How structural typing works
TypeScript is structurally typed: a value fits a type when it has at least the required members, no matter what its type is called or whether anything implements anything.
interface Pet { name: string }
const dog = { name: "Rex", legs: 4 };
const p: Pet = dog; // OK — has name; extra members don't matter
Two separately declared types with the same shape are fully interchangeable, so a TS type reads as a description of a value; the declared name carries no weight. One carve-out exists for typo-catching on inline literals, covered at middle depth.
How narrowing works on union types
string | number means "one of these, and I'm not saying which". Until you narrow, only operations valid for every arm are allowed. Narrowing is ordinary JS checks that the compiler understands:
function fmt(id: string | number | null) {
if (id === null) return "-"; // equality narrows null away
if (typeof id === "string") return id.toUpperCase();
return id.toFixed(2); // only number remains
}
The junior-floor guards: typeof x === "...", equality against a specific value, and "key" in obj for telling object shapes apart. Two traps. typeof x === "object" keeps null in the narrowed type (the classic runtime quirk leaks into the static layer), and truthiness checks like if (x) throw away 0 and "" along with null, which is fine for objects and a bug for string | null.
The compiler tracks all this per branch (control flow analysis) and merges types back after the if. You rarely think about it until it saves you.
strictNullChecks makes absence visible
With strictNullChecks on (assume it always is; without it, null is assignable to everything and the type system is fiction), null and undefined are ordinary union members you must handle. An optional property x?: number reads as number | undefined.
const el = document.getElementById("app"); // HTMLElement | null
el.focus(); // error — could be null
el?.focus(); // narrow, or…
el!.focus(); // …promise the compiler. Unchecked. On you.
! performs no check at compile time or runtime; the compiler believes you and emits nothing. Every ! marks a place where your types couldn't express what you knew. One at an odd corner is normal. A cluster means the type model itself is wrong, and the middle notes cover redesigning it so the assertions disappear.
How discriminated unions model state
When a value has states, give each state its own type and join them in a union sharing a literal-typed discriminant, instead of one type with optional fields:
type Fetch<T> =
| { status: "loading" }
| { status: "success"; data: T } // data REQUIRED here
| { status: "error"; error: Error };
function view(f: Fetch<string>) {
switch (f.status) {
case "success": return f.data; // narrowed — no `?`, no `!`
case "error": return f.error.message;
case "loading": return "…";
default: { const _x: never = f; return _x; }
}
}
Checking the discriminant narrows the whole object to one variant. Compare the "bag" version, { status: string; data?: T; error?: string }: status: string discriminates nothing, data stays T | undefined in every branch, and consumers reach for !. The bag also represents illegal states (success with no data) that every reader must mentally rule out. The union makes them unrepresentable.
The default branch is exhaustiveness checking: while every variant is handled, f narrows to never there, and never assigns to never. Add a fourth status and the assignment stops compiling; the compiler finds every switch you forgot.
Type predicates and assertion functions
When narrowing logic doesn't fit inline, name it:
function isFetched<T>(f: Fetch<T>): f is { status: "success"; data: T } {
return f.status === "success";
}
function assertPresent<T>(x: T | null | undefined): asserts x is T {
if (x == null) throw new Error("required value missing");
}
x is T narrows in the caller's if; asserts x is T narrows everything after the call (it models "throws or it's true"). ❌ The compiler verifies the caller's usage, not the claim: a predicate whose body checks the wrong thing is a lie the whole codebase now believes. Keep bodies trivially auditable.
When excess property checks fire
interface Config { width?: number; color?: string }
declare function draw(c: Config): void;
draw({ width: 1, colour: "red" }); // error — unknown key
const opts = { width: 1, colour: "red" };
draw(opts); // fine
Structurally, extra properties are always allowed (junior level). The inline error is a separate rule: a fresh object literal in an assignment or argument position gets checked for unknown keys, because a key nobody can ever read from there is almost certainly a typo. Route the object through a variable, widen with an assertion, or add an index signature, and the check doesn't fire. ❌ "TS forbids extra properties" and ❌ "the variable version is unsafe/any" are both wrong. The check is a typo lint layered on top of assignability, and assignability itself still allows the extra keys.
Literal widening and as const
const s = "GET" infers the literal type "GET"; let s = "GET" widens to string (it may be reassigned). Object properties widen too:
const req = { url: "/a", method: "GET" }; // method: string
send(req.url, req.method); // error: string ≠ "GET" | "POST"
const req2 = { url: "/a", method: "GET" } as const; // method: "GET", all readonly
Widening is also why a hand-built { status: "success", … } can fail to match a discriminated union: annotate with the union type (or use as const) so the discriminant stays literal.
String-literal unions vs enums comes down to erasure too. Unions erase completely, compare structurally, and narrow with plain ===; enums emit runtime objects and behave nominally. Default to literal unions; reach for an enum only when you genuinely want a runtime value-set to iterate.
unknown vs any at the boundary
Both any and unknown accept every value. The difference is direction: any also assigns to everything and permits every operation, so one any from JSON.parse or a lazy cast silently infects each value derived from it. unknown permits nothing until you narrow, which is exactly right for boundary data:
const raw: unknown = JSON.parse(body);
if (typeof raw === "object" && raw !== null && "id" in raw) { /* earn the type */ }
Discipline: unknown at the edges, narrowing (or a validation library) to enter the typed core, any never as a default. Writing guards once and reusing them across types is parameterized reuse, covered in the ts-generics module.
How variance works for function assignability
Return types compare covariantly (a function returning more is fine where less is expected) and a function taking fewer parameters is assignable where more are offered, which is why arr.map(x => x) may ignore the index. Parameters are the interesting part. Soundness demands contravariance: a handler must accept at least what it will be given:
type Handler = (e: Event) => void;
const h: Handler = (e: MouseEvent) => e.clientX; // unsound: h may get a KeyboardEvent
With strictFunctionTypes on, that assignment errors for function-typed properties. Method declarations stay bivariant on purpose: full contravariance would make Array<Dog> not assignable to Array<Animal> (because push takes the type parameter as input) and break half of practical TS. Interviewers probe for the exact scoping here: parameters are checked contravariantly under strictFunctionTypes, except method syntax, which stays bivariant so common covariant container use keeps working.
Intersections and unions of function types
& on object types merges members (same-key conflicts on primitives collapse to never, unlike interface extends, which errors loudly; prefer extends for object composition). Two sharper cases:
- Intersection of function types ≈ overload set.
((x: string) => A) & ((x: number) => B)is callable either way. - Union of functions ⇒ intersection of parameters. To call
((x: string) => void) | ((x: number) => void)safely, the argument must satisfy both possible signatures, so it must bestring & number, i.e. the call is effectively impossible. Contravariance flips union to intersection. This is the standard "why can't I call this array of mixed callbacks" answer.
satisfies vs annotation vs assertion
One decision rule, in order:
- Default: no annotation. Inference gives the narrowest true type.
- Annotation (
: T) when you want widening: declaring intent up front (let id: string | number), or pinning a public API's shape. satisfies Twhen you need both: validate against a type without losing the narrow inference.asonly when you know something the compiler cannot. Even then it verifies overlap; an impossible coercion needsas unknown as T, which should read as a flare in review.
const palette = {
primary: "#602x1e", // caught: not a valid entry per the record
accent: [255, 0, 0],
} satisfies Record<string, string | [number, number, number]>;
palette.accent.map(n => n / 255); // still known to be the tuple, not the union
Annotating : Record<…> would catch the typo but erase which key holds which shape; as would catch nothing. ❌ "as is how you type a variable": an as-typed object silently drifts out of date when the target type grows a field.
Exhaustiveness checking at scale
The middle-level never check scales into a policy: one shared helper, used in every switch over every discriminated union:
export function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
Now "add a variant" stops being a code-review reminder and becomes a build break at every site that must care, with a runtime throw as the last line of defense for data older than the code. This is the strongest practical argument for modeling with unions: the compiler distributes your change list.
What noUncheckedIndexedAccess fixes
strictNullChecks still lets arrays lie: xs[10] types as number even when it's undefined. With noUncheckedIndexedAccess, every index and dictionary read becomes T | undefined, and each read site needs a check or a redesign (.at(), destructuring with defaults, Map.get semantics you already respect). Cost: real friction in index-heavy code, occasionally a ! where a loop bound genuinely guarantees presence. Whether to enable it is a per-codebase judgment call; the failure it targets, an out-of-bounds read typed as present, exists either way.
Where to be strict and where to loosen
Strictness is a budget. Spend it where types are load-bearing:
- Boundaries (network, storage, user input):
unknownin, runtime validation, then honest types. Erasure means the compiler is blind pastJSON.parse; a type without a validator there is a comment. - Core: inferred, narrow, union-modeled types; assertions treated as defects to burn down.
- Legal loosening: an
aswrapped in a well-named function with a comment stating the invariant; test files; the seam around an untyped dependency. The pattern is quarantine: one lie, contained and documented, instead ofanymetastasizing.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.