Foundations & Mental Model
The idea in one sentence
Section titled “The idea in one sentence”TypeScript is JavaScript plus a static type system that runs at compile time and disappears at runtime. It doesn’t change what your code does — it changes what the compiler will let you write, catching whole classes of bugs before the code ever runs.
Everything advanced in this course — generics, conditional types, mapped types — is just this one system taken to its logical conclusion. Get the mental model right and the rest follows.
What this module covers
Section titled “What this module covers”| Lesson | What you’ll learn |
|---|---|
| Why TypeScript | What types buy you, and the compile-time / runtime split |
| The type-system mindset | Types as sets of values — the single most useful mental model |
| Structural typing | Why TypeScript cares about shape, not names |
| Type inference | How the compiler figures out types you didn’t write |
The lens for the whole course
Section titled “The lens for the whole course”Two ideas, held together, explain almost every TypeScript behavior you’ll ever hit:
flowchart TB sets["Types are SETS of values (number = all numbers)"] --> assign["Assignability = subset relationship"] struct["Typing is STRUCTURAL (shape, not name)"] --> compat["Compatibility = does the shape fit?"] assign --> ts["Every TS behavior"] compat --> ts
When an assignment is allowed, it’s because one type’s set of values fits inside another’s. When two objects are interchangeable, it’s because their shapes match. Hold those two ideas and TypeScript stops surprising you.
Version note. Everything in this course targets TypeScript 7 — the native Go compiler released in July 2026, with strict on by default. Where a feature is recent, the lesson names the version that introduced it (for example “inferred type predicates, 5.5”) so you know what changed since the TypeScript you may have learned.
interface vs type: the first construct choice
Section titled “interface vs type: the first construct choice”You will name your first object shape within minutes of writing TypeScript, and the language gives you two ways to do it: interface and type. For a plain object shape they behave almost identically — but “almost” hides two real differences worth knowing from day one.
Declaration merging. An interface can be declared more than once in the same scope; the declarations merge into one interface with every member from every declaration. A type alias cannot — redeclaring one is a hard error.
interface Config { name: string;}interface Config { version: number;}
const c: Config = { name: "app", version: 1 }; // ✅ merged: both members required// @expect-error TS2300type Config = { name: string;};type Config = { version: number;};Merging isn’t a curiosity — it’s how .d.ts files extend things you don’t own. Adding a method to every array, globally, looks exactly like the merge above:
interface Array<T> { last(): T | undefined;}
const nums: number[] = [1, 2, 3];console.log(nums.last()); // ✅ Array<T> merged with lib.es5.d.ts's own Array<T>This only works because Array is declared with interface in TypeScript’s own lib files. A library that wanted the same trick with type simply couldn’t — this is the concrete reason libraries publish public object contracts as interface.
Error messages read differently once the shape gets composed. For a single flat shape, interface and type produce identical-looking errors. The difference shows up once you compose one from another — extends for an interface, & for a type alias:
Composing a shape changes what the error names
// @expect-error TS2345 TS2741interface Base { id: string }interface IUser extends Base { name: string }
type TBase = { id: string };type TUser = TBase & { name: string };
function greetI(u: IUser) {}function greetT(u: TUser) {}
greetI({ id: "1" }); // TS2741: missing 'name' in type 'IUser' — named by the interfacegreetT({ id: "1" }); // TS2345, with a nested line naming '{ name: string }' — the anonymous intersection member, not 'TBase' interface extends keeps naming the interface (IUser) no matter how many levels it’s built from. type intersection reports assignability against the alias name at the top, but the moment it has to explain which piece failed, it expands to the anonymous object type of the failing intersection member instead of naming TBase. Neither is “wrong” — but a codebase with deeply intersected type aliases reads noisier errors than the equivalent interface extends chain.
When the course recommends each. Reach for interface for object shapes you expect to be extended, implemented by a class, or augmented by someone else’s code (the merging behavior is a feature there). Reach for type for anything an interface cannot express at all — unions, tuples, mapped types, conditional types — and for one-off shapes where merging would only be a footgun. Both are erased at compile time either way; this is a readability and API-design choice, not a performance one.
Reading an “is not assignable” error chain
Section titled “Reading an “is not assignable” error chain”Beginners often read a multi-line TypeScript error top to bottom and try to fix the first line. That’s backwards — the first line is the conclusion, and the real problem is at the bottom.
// @expect-error TS2322interface Address { city: string; zip: number }interface Person { name: string; address: Address }
const raw = { name: "Ada", address: { city: "NYC", zip: "10001" } };const p: Person = raw;error TS2322: Type '{ name: string; address: { city: string; zip: string; }; }' is not assignable to type 'Person'. The types of 'address.zip' are incompatible between these types. Type 'string' is not assignable to type 'number'.Read it bottom-up:
- Bottom line — the actual mismatch.
Type 'string' is not assignable to type 'number'.This is the one real fact: somewhere, astringmet anumberand didn’t fit. - Middle line — the exact location.
The types of 'address.zip' are incompatible...names the property path. You now know where:raw.address.zip, notrawitself. - Top line — the summary a human asked for.
Type '{...}' is not assignable to type 'Person'.This is what triggered the check in the first place; it restates the whole object because that’s what you tried to assign, but it names nothing you can act on directly.
The fix is always at the bottom: zip needs to be a number (parse it, or fix wherever "10001" was produced as a string). Chasing the top line instead — casting raw to Person, or loosening Person.address to any — silences the message without touching the actual bug.
Mental Model
Section titled “Mental Model”A type error is not a wall of text to skim; it’s a description of the smallest place two shapes disagree. The compiler always tells you, somewhere in the message, exactly which property and which two types failed to line up — everything above that line is just how the mismatch was reached, not what’s wrong.
flowchart BT bottom["Type string is not assignable to type number (the real mismatch)"] --> middle["at address.zip (the exact location)"] middle --> top["object not assignable to Person (the summary — not actionable on its own)"]
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
interface for a public shape | Can be extended, implemented by a class, and merged/augmented by other files or .d.ts declarations | Cannot express unions, tuples, or other non-object shapes |
type for a public shape | Expresses anything — unions, tuples, mapped/conditional types — and cannot be silently re-opened elsewhere | No merging: two declarations with the same name is a hard error, not a feature |
Fixing the top line of an error (cast, any) | Makes the red squiggle disappear immediately | Doesn’t touch the actual mismatch — it usually resurfaces later as a runtime bug instead of a compile error |
| Fixing the bottom line of an error | Fixes the real problem at its source | Requires reading past the summary line, which is slower the first few times |
Common Misconceptions
Section titled “Common Misconceptions”interfaceandtypeare basically interchangeable, so it doesn’t matter which you pick. True for one flat, never-reopened shape. False the moment merging,extends/&composition, or a non-object shape enters the picture — and error-message shape already differs by then too.- A long, multi-line error means a complicated bug. Usually it means a deeply nested but perfectly simple bug: one leaf property has the wrong type, and the compiler is showing you the path to it, not the difficulty of the fix.
- The top line of an error is the thing to fix. The top line is almost always the least specific part of the message — a summary of what you attempted, not a diagnosis. The specific claim is at the bottom.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated code that hits a type error has a predictable failure mode: it “fixes” the outer line instead of the inner one. When reviewing a diff that resolves a type error, check:
- Did the fix change the type of the leaf value (the actual
stringthat should have been anumber), or did it just cast away the outer variable (as Person,as unknown as Person) so the checker stops complaining? - If the fix is a cast, would the original, more specific error still be true about the data at runtime? A cast doesn’t change what the value actually is.
- Does the “fixed” code still crash if you mentally run it against the real (uncast) shape?
🐛 Spot the bug
interface Address { city: string; zip: number }interface Person { name: string; address: Address }
const raw = { name: "Ada", address: { city: "NYC", zip: "10001" } };const p: Person = raw as unknown as Person; // "fixed" the red squiggleconsole.log(p.address.zip.toFixed(0));Show the answer
This compiles with zero errors and crashes at runtime. The original, un-cast assignment (const p: Person = raw;) correctly reported that address.zip is a string, not a number — that was real, useful information about the data. raw as unknown as Person doesn’t change what raw.address.zip actually is (still the string "10001"); it only tells the compiler to stop checking. p.address.zip.toFixed(0) then calls a number method on a string and throws TypeError: p.address.zip.toFixed is not a function. The correct fix was at the bottom of the original error, not at the top: convert the raw zip code to a real number before it reaches raw, or accept zip: string in Address if a string is what the data actually is.