TypeScript · Basics
Type basics & inference
⚡ TL;DR — TypeScript is a compile-time layer that is erased to nothing before your code runs, so it gives you zero runtime safety; its job is to infer and check the shapes flowing through your program, and the skill is knowing when to let inference work and when to annotate the inputs it can't see.
🧠 Mental model
Think of TS as a spellchecker for values, not a runtime guard. Every type you write vanishes during compilation (tsc and esbuild both just strip them), so a number parameter can absolutely receive a string at runtime if the value came from JSON, the DOM, or an any. The type system is a proof that, given your assumptions hold, the shapes line up. If the assumptions are wrong — an API returns a different shape — TS is silent, because it was never there when the bytes arrived.
The second idea: inference is the default, annotation is the exception. TS derives types from the right-hand side of assignments and from return positions. You annotate the boundaries — function parameters, external data, public APIs — because those are the places TS genuinely cannot infer intent.
⚙️ How it actually works
Inference has two modes that trip people up. Widening: let x = 3 infers number (mutable, so it could become any number), but const x = 3 infers the literal 3. The const context tells TS the value can never change, so it keeps the narrowest type. This is why const vs let changes your types, not just your runtime binding.
Contextual typing flows the other direction — from expected type into an expression. In arr.map(x => x.toUpperCase()), x is typed from arr's element type; you don't annotate it. Same with onClick={e => …} in typed JSX — the event type comes from the prop. Annotating these manually is a smell.
The escape hatch is any, and it's radioactive: an any disables checking on everything it touches. const data: any = fetch(...); data.foo.bar.baz compiles, and every downstream variable that reads from data silently becomes any too. This is how a single unchecked boundary poisons a whole module.
💻 Code
// Inference vs annotation — annotate inputs, let outputs infer
function double(n: number) { // ✅ annotate the parameter (a boundary)
return n * 2; // return type inferred as number — no annotation needed
}
let a = 3; // number (widened, because let is mutable)
const b = 3; // 3 (literal type kept, because const can't change)
// ❌ any turns off the type checker silently
const raw: any = JSON.parse('{"n": 1}');
const wrong: number = raw.doesNotExist.atAll; // compiles! crashes at runtime.
// ✅ types are erased — this is what actually ships
function greet(name: string): string { return `hi ${name}`; }
// compiles to: function greet(name) { return `hi ${name}`; }
// The boundary problem: TS trusts your annotation, the network doesn't
const user = await fetch('/api/me').then(r => r.json()); // r.json() is Promise<any>
// user is `any` — every property access is unchecked. Validate here (see Zod).
⚖️ Trade-offs
- Inference keeps code DRY and refactor-safe — change a return value and every caller updates. But over-inferring public API return types leaks internals and makes the type contract implicit; annotate exported function returns so the boundary is stable and errors surface at the definition, not the call site.
anyis occasionally pragmatic (migrating JS, genuinely dynamic metaprogramming) but it's a hole in the net, not a tool. Reach forunknownfirst.- Don't annotate what TS already knows.
const n: number = 5is noise;const els: HTMLElement[] = [...]where the right side is already typed is noise. Redundant annotations rot when the source changes.
💣 Gotchas interviewers probe
- "Does TypeScript make my code safe at runtime?" No. Types are erased. The only runtime safety comes from actual runtime code (validation, guards). This is the #1 thing they want you to say unprompted.
letvsconstchange inferred types.const s = 'GET'is"GET";let s = 'GET'isstring. Matters hugely when passing to functions expecting a literal union.anyis contagious,unknownis contained. Reading a property offanygivesany; you can't touchunknownwithout narrowing first.- Excess property checks only fire on fresh object literals.
fn({ a: 1, b: 2 })errors onb; assigning to a variable first launders the check away. noImplicitAnyis the real baseline. Without it, an un-annotated parameter is silentlyanyand you get none of TS's value.
🎯 Say this in the interview
"The mental model I lead with is that TypeScript is entirely compile-time — every type is erased before the code runs, so it gives me zero runtime safety. Its value is catching shape mismatches during development and powering editor tooling. Practically, I let inference do the work inside a function and I annotate the boundaries: parameters, exported return types, and anything crossing into the program from outside — network, storage, the DOM — because that's where TS can't see the real shape. I avoid
anybecause it silently disables checking on everything it touches and spreads; I useunknownwhen a value is genuinely dynamic and narrow it before use. And at true runtime boundaries likefetch, I validate with something like Zod, because the type annotation is a promise TS can't enforce."
🔗 Go deeper
- TS Handbook — Everyday types — the canonical tour of the primitives and annotation syntax.
- TS Handbook — Type inference — widening, best-common-type, and contextual typing explained.
- TS Handbook —
any— why it opts out of checking, straight from the source.