← Fundamentals
Guided lesson 5 questions · 6 languages

Static and dynamic type checks

What the compiler proves before the run, and what only a running value can show.

A check before the program runs covers every path through the code, but it knows only types, not the values that will arrive. A check while the program runs sees the real value, but only the values of that run. For each operation, know which of the two protects it in your language, and write the missing check yourself.

Before you begin

Why this matters

TypeScript checks your code before it runs and then removes every type. Knowing what that check proves, and what it cannot see, tells you where a wrong value can still get in and where you must check it yourself.

What this helps you reason about

  • Predict whether a type mistake on a path that never runs stops the program.
  • Explain why reading past the end of an array gives no error at that line.
  • Spot an as or a JSON.parse that lets a wrong value in, and replace it with a check.

An airport screen shows a boarding gate and a delay. This lesson asks which mistakes in such a program TypeScript finds before it runs, which ones JavaScript finds while it runs, and which ones nothing finds.

Already know this? The short version
  • tsc checks every path before the run, then erases the types; the running JavaScript never sees them, so TypeScript adds no checks while the program runs.
  • JavaScript’s own run-time checks remain: calling a non-function or reading a property of undefined or null throws a TypeError. A read past the end of an array is not checked; it gives undefined.
  • any, as, and JSON.parse switch the compile-time check off for a value. Parse outside data as unknown and narrow it with typeof, Array.isArray, and in, once, where it enters.
  • noUncheckedIndexedAccess adds undefined to the type of every indexed read, so the compiler makes you handle a missing element.

Go to the final check· Go to the practice tickets

Words in this lesson (6)
type
The kind of a value, such as number, string, or boolean. It decides which operations TypeScript allows on the value.
compiler
A program that checks your code and translates it into another form. The TypeScript compiler checks types, then removes them and produces plain JavaScript.
static check
A check the compiler makes on the code before the program runs. It covers every path through the code, including branches that a particular run never takes.
dynamic check
A check made while the program runs, on the value that is actually there, such as whether a position exists in an array or list. It sees only the values of that run.
type assertion
An expression such as value as Flight, which tells the compiler to treat a value as a given type. It checks nothing while the program runs, so the value can still have another type.
unknown
A type for a value whose type is not known yet. The compiler accepts no use of the value until a check, such as typeof value === "number", has decided its type.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / Before the run

What is checked before the program runs?

As Variables showed, TypeScript checks types before the program runs and then removes them. A check made before the program runs is called a static check. It reads the code, not a run of it, so it covers every path: every branch, even one that no input reaches today.

In the program below, boardingText is called with 15, so the branch for more than 60 minutes never runs. The compiler checks it all the same. If that branch returned minutes, a number, from a function whose result type is string, the compiler would report Type 'number' is not assignable to type 'string', and no line of the program would run.

A static check knows the type of every name. It does not know the values a run will bring, so it cannot know which branch a run takes.

main.ts
function boardingText(gate: string, minutes: number): string {
  if (minutes > 60) {
    return "Gate " + gate + " opens later";
  }
  return "Board now at " + gate;
}

console.log(boardingText("B4", 15)); // Board now at B4

A static check covers every path, but it knows only types, not the values a run will bring.

Coming from Python: TypeScript’s types work like Python’s type hints checked by a separate tool such as mypy: the checker reads them, and the running program never sees them.

Precisely: checking is a separate step

The stepper runs the compiler with strict mode on and refuses to run a program with type errors. tsc on its own still writes JavaScript when there are type errors, unless noEmitOnError is set. Recent versions of Node.js can also run a .ts file directly by removing the types without checking them; then no static check happens at all.

Experiment

Find a type error in a branch that never runs

The compiler checks every function body before the program starts, branch by branch. It does not need a run that reaches the branch: the declared result type and the type of minutes are enough to see the mistake.

Why it matters A check before the run finds a type mistake on a path that no test reaches. It cannot tell you which path a run will take, because it never sees the values.

Predict whether the stepper ever stops on line 3. Then change return "Gate " + gate + " opens later"; to return minutes; and predict whether any line of the program runs.

function boardingText(gate: string, minutes: number): string {
  if (minutes > 60) {
    return "Gate " + gate + " opens later";
  }
  return "Board now at " + gate;
}

console.log(boardingText("B4", 15));
What to look for

Step into boardingText. Its frame shows gate "B4" and minutes 15, and the next stop is on line 5: line 3 never runs. The run prints Board now at B4. After the edit, nothing runs; the compiler reports Line 3: Type 'number' is not assignable to type 'string'. <top level> is the stepper’s name for the code outside every function, and the panel lists boardingText as function boardingText(gate, minutes). Step sometimes stops on the same line twice; that is normal for this debugger.

Expected output

Board now at B4

02 / While it runs

What is checked while the program runs?

Some questions depend on a value that exists only while the program runs. Is this position inside the list? Does this value really hold a number? A check made at that moment, on the value that is actually there, is called a dynamic check. It sees only the values of the current run.

TypeScript adds no dynamic checks of its own, because its types are gone when the program runs. The checks that remain are JavaScript’s. Reading a position that an array does not have is not one of them: gates[3] gives undefined, and the result type of gateFor still says string. The mistake shows later, at the first operation that JavaScript does check. In the experiment below, that is toLowerCase(), which returns a string in small letters: called on undefined, it throws TypeError: Cannot read properties of undefined.

main.ts
const gates = ["A1", "B4", "C2"];

function gateFor(row: number): string {
  return gates[row];
}

console.log(gateFor(1)); // B4
console.log(gateFor(3)); // undefined

A static check knows the type of an array element, not whether the position exists. JavaScript does not check it either.

Precisely: what JavaScript checks

JavaScript throws a TypeError when code calls something that is not a function, or reads a property of undefined or null. It does not check array positions, and 1 / 0 is Infinity, not an error. The compiler option noUncheckedIndexedAccess gives gates[row] the type string | undefined, so the compiler makes you handle a missing element before you use it.

Experiment

Read past the end of an array

gates[3] asks for a position the array does not have. JavaScript does not treat that as an error; it gives undefined. The result type string was checked before the run and then removed, so nothing compares undefined with it. The first check that fails is JavaScript’s own: reading a property of undefined.

Why it matters In TypeScript, a read past the end of an array is not caught where it happens. The value travels on as undefined until some later operation fails, or never fails at all.

Predict what is printed for row 1. Then change const row = 1; to const row = 3; and predict on which line the program reports a problem.

const gates = ["A1", "B4", "C2"];

function gateFor(row: number): string {
  return gates[row];
}

const row = 1;
console.log(gateFor(row).toLowerCase());
What to look for

Step into gateFor. row shows 1 and gates shows ["A1", "B4", "C2"]. The run prints b4. After the edit, row shows 3, and gateFor returns without any error. Back on line 8, the run stops with TypeError: Cannot read properties of undefined (reading 'toLowerCase'): the problem is reported one line after the read that caused it.

Expected output

b4

03 / Values from outside

What happens to a value whose type the compiler cannot know?

The compiler can check only types it knows. Text from outside the program, such as JSON from a file or a server, gets its shape only when the program runs. JSON.parse turns JSON text into a value and returns the type any, which the compiler accepts as every type and checks nothing about.

value as Flight is a type assertion: it tells the compiler to treat the value as a Flight. It is not a check. It is removed with the other types, so if delay arrives as the string "15", + joins two strings, and flight.delay + 5 is "155". type Flight = { … } gives a name to an object type, and a string in single quotes can contain double quotes.

main.ts
type Flight = { gate: string; delay: number };

const text = '{"gate": "B4", "delay": "15"}';
const flight = JSON.parse(text) as Flight;
console.log(flight.delay + 5); // 155

any and as switch the static check off for a value, and nothing checks the value while the program runs instead.

Coming from Java: A Java cast is checked while the program runs and can throw a ClassCastException; a TypeScript as is removed before the run and never fails.

Precisely: data from a server

response.json(), from the fetch function, also returns any. So const user = (await response.json()) as User checks nothing, in the same way. A helper such as async function getJson<T>(url: string): Promise<T> only moves the same as inside the helper: the caller writes getJson<User>(url), and nothing compares the data with User.

Experiment

Trust a type assertion on JSON

as Flight changes what the compiler believes, not the value. The types are removed before the run, so nothing compares the parsed value with Flight. + with a string joins text, so 5 is added to the end of "15".

Why it matters A value from JSON.parse has whatever shape the text had. Until a check made while the program runs has looked at it, its type is a hope.

Predict what later holds. Then change "delay": "15" in the text to "delay": 15, without quotes around the number, and predict again.

type Flight = { gate: string; delay: number };

const text = '{"gate": "B4", "delay": "15"}';
const flight = JSON.parse(text) as Flight;
const later = flight.delay + 5;
console.log(later);
What to look for

Step past the JSON.parse line. text shows each inner quote as \", as it would be written inside double quotes. flight shows {gate: "B4", delay: "15"}: delay holds text, in quotes, although the type Flight says number. later shows "155", and the run prints 155. After the edit, flight shows delay: 15, later shows 20, and the run prints 20.

Expected output

155

04 / Your own check

Where should your own check go?

When a value comes from outside, check it once, where it enters the program. Give it the type unknown: the compiler then accepts no use of it until a check has decided its type. typeof value === "number" is such a check. Inside the if, the compiler treats the value as a number, and from there on the static check is true again.

readDelay below returns number | null: a number, or null when the text did not hold one. The code that calls it must handle null before it can use the number.

For larger objects, a validation library such as the Zod library builds this check from one description of the type, and gives the compiler the same type.

main.ts
function readDelay(text: string): number | null {
  const delay: unknown = JSON.parse(text);
  if (typeof delay === "number") {
    return delay;
  }
  return null;
}

console.log(readDelay("15")); // 15
console.log(readDelay('"15"')); // null

Check a value from outside once, where it enters, and let the static check carry the result from there.

Precisely: narrowing objects

For an object, check each property you use: typeof data === "object" && data !== null, then "delay" in data, then typeof data.delay === "number". Array.isArray(value) checks for an array. Each check narrows the type the compiler uses inside its if. For an unknown value, "delay" in data narrows the type so that data.delay can be read only in TypeScript 4.9 and later.

05 / Try it

Can you explain a fresh example?

Decide for each value whether anything checked it. Every check in this lesson is about types or positions. Assertions, tests, and counterexamples looks for inputs that pass every such check and still give a wrong result.

main.ts
const seats = [3, 0, 5];
const picked = JSON.parse('"2"') as number;
console.log(seats[2], seats[5], picked + 1);

What is printed?

Reveal the reasoning

5 undefined 21. seats[2] exists. seats[5] does not, and JavaScript gives undefined without an error, although its type is number. picked is the text "2": as number changed only what the compiler believes, so picked + 1 joins text and gives "21".

Practice tickets

  1. TYPE-101 · Mistyped night counts still produce a hostel bill → Free
  2. TYPE-102 · A note in the tour bus log breaks the passenger count → Sign in · Pro
  3. TYPE-103 · The night train’s berth screen gets the end berths wrong → Sign in · Pro

Keep exploring

Assertions, tests, and counterexamples →

Primary references