Skip to content

Foundations & Mental Model

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.

LessonWhat you’ll learn
Why TypeScriptWhat types buy you, and the compile-time / runtime split
The type-system mindsetTypes as sets of values — the single most useful mental model
Structural typingWhy TypeScript cares about shape, not names
Type inferenceHow the compiler figures out types you didn’t write

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
The two ideas everything builds on

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 TS2300
type 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 TS2741
interface 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 interface
greetT({ id: "1" }); // TS2345, with a nested line naming '{ name: string }' — the anonymous intersection member, not 'TBase'
Open in TS Playground

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 TS2322
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;
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:

  1. Bottom line — the actual mismatch. Type 'string' is not assignable to type 'number'. This is the one real fact: somewhere, a string met a number and didn’t fit.
  2. Middle line — the exact location. The types of 'address.zip' are incompatible... names the property path. You now know where: raw.address.zip, not raw itself.
  3. 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.

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)"]
A nested error chain: read from the bottom
ChoiceBenefitCost
interface for a public shapeCan be extended, implemented by a class, and merged/augmented by other files or .d.ts declarationsCannot express unions, tuples, or other non-object shapes
type for a public shapeExpresses anything — unions, tuples, mapped/conditional types — and cannot be silently re-opened elsewhereNo 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 immediatelyDoesn’t touch the actual mismatch — it usually resurfaces later as a runtime bug instead of a compile error
Fixing the bottom line of an errorFixes the real problem at its sourceRequires reading past the summary line, which is slower the first few times
  • interface and type are 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.

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 string that should have been a number), 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 squiggle
console.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.

When does TypeScript's type system do its work?
What is the most useful mental model for a TypeScript type?
Which two ideas explain most TypeScript behavior?
Can interface Config { name: string } be declared again elsewhere with interface Config { version: number }?
What does the resulting error name for the missing property, and why?
interface Base { id: string }
interface IUser extends Base { name: string }

type TBase = { id: string };
type TUser = TBase & { name: string };

function greetT(u: TUser) {}
greetT({ id: "1" });
Which line contains the actionable fact, and in which direction should you read?
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'.
A diff "fixes" a type error by changing const p: Person = raw; to const p: Person = raw as unknown as Person;. Does this fix the underlying problem the original error was reporting?