LEVEL 5 · LESSON 2 OF 21

Why TypeScript Foundation

What the TypeScript compiler does

Follow a TypeScript file through tsc: checking, type erasure, transpiling to older JavaScript, emitted files, watch mode and Node's type stripping.

  • 45 min to read and try
  • You need: Why TypeScript exists (the ts-tasks project with tsc, tsx and a tsconfig.json)
  • You build: A small bank-transfer module you check, compile, run with Node's type stripping and wire into package scripts
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Explain what "superset" and "static type checking" mean, and what they do not promise
  • Predict which parts of a .ts file survive in the emitted JavaScript
  • Use target, noEmit, noEmitOnError, declaration, sourceMap and watch mode on purpose
  • Run TypeScript with Node's type stripping and name its limits
  • Set up package scripts so that type checking actually happens before code ships
  • Explain why a program that compiles still needs runtime validation

The refund that added ₦50 wrong

A small payments team keeps its settings in an object. A new teammate adds the transfer fee, and writes it the way it appears in the finance spreadsheet: as text. Then a refund function gives the fee back to the customer:

refund.ts
function refund(balance: number, fee: number): number {
  return balance + fee;
}

const settings = { transferFee: "50" };
console.log(refund(5000, settings.transferFee));
What npx tsc --noEmit prints
refund.ts:6:26 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

6 console.log(refund(5000, settings.transferFee));
                           ~~~~~~~~~~~~~~~~~~~~


Found 1 error in refund.ts:6

TypeScript sees the mistake at once. But the team's deploy script never runs tsc. It starts the server with node src/server.ts, which Node.js 24 can do, and it runs this code happily:

Terminal on your computer (Node.js 24 from nodejs.org)
node refund.ts
500050

The customer had ₦5,000 and was owed ₦50 back. The account now says ₦500,050, because 5000 + "50" is string concatenation in JavaScript. Here is exactly what Node ran, with the types removed:

refund.js
function refund(balance, fee) {
  return balance + fee;
}

const settings = { transferFee: "50" };
console.log(refund(5000, settings.transferFee));
Output of node refund.js and of the browser terminal
500050

The team had TypeScript. What they did not have was the checking step. This lesson takes the compiler apart so that you always know which tool checks, which tool only runs, and what is left of your types when the code finally executes. You met the three ways to run TypeScript in Why TypeScript exists; here you go underneath them.

What "superset of JavaScript" really means

TypeScript is often called a superset of JavaScript. A superset is a larger set that contains a smaller one. For languages, it means: every piece of JavaScript syntax is also TypeScript syntax, and TypeScript adds more syntax on top (annotations, interface, type, generics and a few others).

It does not mean that every JavaScript program passes the TypeScript checker. This is valid JavaScript, and runs without an error in any browser:

balance.ts
let balance = 5000;
balance = "empty";

const account = { owner: "Ada", balance: 5000 };
console.log(account.balnce);
What npx tsc --noEmit prints
balance.ts:2:1 - error TS2322: Type 'string' is not assignable to type 'number'.

2 balance = "empty";
  ~~~~~~~

balance.ts:5:21 - error TS2551: Property 'balnce' does not exist on type '{ owner: string; balance: number; }'. Did you mean 'balance'?

5 console.log(account.balnce);
                      ~~~~~~

  balance.ts:4:33 - 'balance' is declared here.
    4 const account = { owner: "Ada", balance: 5000 };
                                      ~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: balance.ts:2

Both lines are legal JavaScript. Both are almost certainly bugs, and TypeScript refuses them. So there are two separate questions about any .ts file:

  1. Is it valid syntax? Can the file be read at all, and turned into JavaScript? Every tool (tsc, tsx, Node.js) must answer this.
  2. Do the types fit? Does every value match what the code says it should be? Only the type checker answers this, and in a normal project that means tsc (or your editor, which runs the same checker in the background).

The second question is answered by static type checking. "Static" means the code is analysed without running it: the checker reads the text, works out a type for every expression, and compares them. The opposite is dynamic checking, which happens while the program runs, like JavaScript throwing TypeError: x is not a function on the line that fails.

Compile timeRuntime
WhenWhen tsc reads your filesWhen Node.js or a browser executes the JavaScript
What existsYour source text, and the types in itValues in memory. No types.
What it can knowEvery path through the code, including paths that never run in your testsOnly the values that actually arrive
What it cannot knowWhat a user, a file or another server will sendAnything about paths that did not run

The two columns cover each other's blind spots. That is why a serious backend uses both: tsc for the code you wrote, and runtime checks for the data you did not.

Inside tsc: parse, check, emit

tsc is really three programs run one after another:

  transfer.ts, receipt.ts, …
          │
          ▼
  ┌───────────────┐   syntax errors (a missing brace, a stray keyword)
  │ 1. Parse      │ ─────────────────────────────────────────────────▶
  └───────┬───────┘
          │ a tree of every statement and expression
          ▼
  ┌───────────────┐   type errors (TS2322, TS2345, …)
  │ 2. Check      │ ─────────────────────────────────────────────────▶
  └───────┬───────┘
          │ the same tree, unchanged by the check
          ▼
  ┌───────────────┐
  │ 3. Emit       │ ──▶ .js files  (+ .d.ts and .js.map when asked)
  └───────────────┘
  • Parse reads the text into a tree, just like a JavaScript engine does. A missing } fails here.
  • Check walks the tree and works out types. This is the expensive part, and the only part that finds type errors.
  • Emit writes JavaScript. It removes the types and, if you ask for older JavaScript, rewrites newer syntax. It does not use the results of the check at all.

That last point surprises people: checking and emitting are independent. By default, tsc writes the JavaScript even when the check fails. Try it with the refund file in your ts-tasks folder (which has "noEmit": true in tsconfig.json, so turn it off for this run):

Terminal on your computer
npx tsc --noEmit false --outDir dist
refund.ts:6:26 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

6 console.log(refund(5000, settings.transferFee));
                           ~~~~~~~~~~~~~~~~~~~~


Found 1 error in refund.ts:6

echo $?
2
ls dist
refund.js

echo $? prints the exit code of the last command: a number every program hands back to the shell when it ends. 0 means success, anything else means failure, and CI systems stop a build on anything but 0. tsc uses three codes:

Exit codeMeaning
0No errors. Output written (unless noEmit).
1Errors, and no output was written (for example with --noEmit or --noEmitOnError).
2Errors, but the output was written anyway.

The broken dist/refund.js is sitting there, ready for someone to deploy. Turn on noEmitOnError and tsc refuses to write anything while there are errors:

Terminal on your computer
rm -rf dist
npx tsc --noEmit false --outDir dist --noEmitOnError
refund.ts:6:26 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
…
echo $?
1
ls dist
ls: cannot access 'dist': No such file or directory

TIP

For a build that produces files you will deploy, set "noEmitOnError": true in tsconfig.json. For a project that only checks ("noEmit": true), it makes no difference: nothing is written anyway.

Type erasure, line by line

In TypeScript and JavaScript together you will see that an interface, an annotation and an as all disappear from the emitted code. This is type erasure: removing every piece of type information and keeping only the JavaScript. Here you see the full list of what goes. Start with two files of a small transfer module:

transfer.ts
export interface Transfer {
  from: string;
  to: string;
  amount: number;
}

export function transferFee(amount: number): number {
  if (amount <= 5000) return 10;
  if (amount <= 50000) return 25;
  return 50;
}
receipt.ts
import { transferFee, type Transfer } from "./transfer.js";

interface Receipt {
  reference: string;
  total: number;
}

type Currency = "NGN" | "USD";

function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}

function receiptFor(transfer: Transfer, currency: Currency = "NGN"): Receipt {
  const total = transfer.amount + transferFee(transfer.amount);
  return { reference: `${currency}:${transfer.from}->${transfer.to}`, total } satisfies Receipt;
}

const transfers: Transfer[] = [{ from: "Ada", to: "Bola", amount: 2500 }];
const latest = first(transfers)!;
const receipt = receiptFor(latest) as Receipt;
console.log(receipt);
Output of npx tsx receipt.ts and of the browser terminal
{ reference: 'NGN:Ada->Bola', total: 2510 }

Before you look at the compiled file, guess which parts of receipt.ts will be gone. Then compile:

Terminal on your computer
npx tsc --noEmit false --outDir dist
dist/receipt.js
import { transferFee } from "./transfer.js";
function first(items) {
    return items[0];
}
function receiptFor(transfer, currency = "NGN") {
    const total = transfer.amount + transferFee(transfer.amount);
    return { reference: `${currency}:${transfer.from}->${transfer.to}`, total };
}
const transfers = [{ from: "Ada", to: "Bola", amount: 2500 }];
const latest = first(transfers);
const receipt = receiptFor(latest);
console.log(receipt);

Everything that was only about types is gone, and nothing was added in its place:

In receipt.tsIn receipt.js
type Transfer inside the importRemoved. The import keeps only transferFee, a real function.
interface Receipt, type CurrencyRemoved completely
<T>, readonly T[], T | undefinedRemoved. first is one plain function for every T.
: Transfer, : Currency, : ReceiptRemoved
= "NGN"Kept: a default value is JavaScript
satisfies ReceiptRemoved. The check happened at compile time only.
! after first(transfers)Removed. No check that the value is really there.
as ReceiptRemoved. No conversion, no check.

The last three rows deserve a second look. ! (the non-null assertion) and as (a type assertion) are promises you make to the compiler. The compiler believes you, and then erases the promise. If transfers had been empty, latest would be undefined at runtime, and nothing would stop it. Type assertions covers when such promises are justified.

Generics do not exist at runtime either

A generic function looks as if it knows its type parameter. It does not. Here is a function that promises to return a T from a JSON string:

parse.ts
interface Transfer {
  from: string;
  to: string;
  amount: number;
}

function parseJson<T>(text: string): T {
  return JSON.parse(text);
}

const transfer = parseJson<Transfer>('{"from": "Ada", "to": "Bola", "amount": "2500"}');
console.log(typeof transfer.amount);
console.log(transfer.amount + 10);
Output of npx tsx parse.ts and of the browser terminal
string
250010

The compiler believes transfer.amount is a number, and allows + 10. After erasure, parseJson is just function parseJson(text) { return JSON.parse(text); }. The <Transfer> in the call vanished, so nothing compared the JSON with Transfer. A type parameter can shape what the compiler believes; it can never make the code check anything.

You cannot branch on a type

Because types are gone at runtime, you cannot use one as a value. Say you want to check that a currency code from a form is one you support:

currency.ts
type Currency = "NGN" | "USD" | "GBP";

function isSupported(code: string): boolean {
  return Currency.includes(code);
}
What npx tsc --noEmit prints
currency.ts:4:10 - error TS2693: 'Currency' only refers to a type, but is being used as a value here.

4   return Currency.includes(code);
           ~~~~~~~~


Found 1 error in currency.ts:4

The fix is to start from a value, which survives, and derive the type from it. Then the list exists once, for both worlds:

currency.ts
const currencies = ["NGN", "USD", "GBP"] as const;
type Currency = (typeof currencies)[number];

function isSupported(code: string): boolean {
  return currencies.some((c) => c === code);
}

const home: Currency = "NGN";
console.log(home, isSupported("USD"), isSupported("EUR"));
Output of npx tsx currency.ts and of the browser terminal
NGN true false

as const keeps the array's exact strings, and (typeof currencies)[number] turns them into the union "NGN" | "USD" | "GBP". Advanced and utility types explains both. Here, the point is the direction: values can produce types, but types can never produce values.

The syntax that is not erased

Almost all TypeScript syntax is erasable. A few older features are not: the compiler has to write new JavaScript for them. The two you will meet most often are enum and parameter properties (a constructor parameter marked public, private, protected or readonly, which you will meet in Classes in TypeScript).

account.ts
enum Currency {
  NGN = "NGN",
  USD = "USD",
}

class Account {
  constructor(
    public readonly owner: string,
    private balance: number,
  ) {}

  describe(): string {
    return `${this.owner}: ${Currency.NGN} ${this.balance}`;
  }
}

console.log(new Account("Ada", 5000).describe());
Output of npx tsx account.ts and of the browser terminal
Ada: NGN 5000
dist/account.js
var Currency;
(function (Currency) {
    Currency["NGN"] = "NGN";
    Currency["USD"] = "USD";
})(Currency || (Currency = {}));
class Account {
    owner;
    balance;
    constructor(owner, balance) {
        this.owner = owner;
        this.balance = balance;
    }
    describe() {
        return `${this.owner}: ${Currency.NGN} ${this.balance}`;
    }
}
console.log(new Account("Ada", 5000).describe());
export {};

The enum became a variable and a function that fills it. The two constructor parameters became two class fields and two assignments. None of that code was in your file; tsc generated it. (The last line, export {};, marks the file as an ES module because it has no imports or exports of its own.) namespace blocks, which old TypeScript code uses to group values, also generate code.

Code like this is a problem for any tool that only strips types, such as Node.js. Since TypeScript 5.8 there is a setting that bans it, erasableSyntaxOnly. Add it to the project's tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"],
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true
  }
}
account.ts
enum Currency {
  NGN = "NGN",
  USD = "USD",
}

class Account {
  constructor(
    public readonly owner: string,
    private balance: number,
  ) {}
}
What npx tsc --noEmit prints
account.ts:1:6 - error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

1 enum Currency {
       ~~~~~~~~

account.ts:8:5 - error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

8     public readonly owner: string,
      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

account.ts:9:5 - error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

9     private balance: number,
      ~~~~~~~~~~~~~~~~~~~~~~~


Found 3 errors in the same file, starting at: account.ts:1

Every line that would generate code is marked. The erasable version does the same job with plain JavaScript plus types: a const object instead of the enum, and ordinary fields instead of parameter properties.

account.ts
const Currency = {
  NGN: "NGN",
  USD: "USD",
} as const;
type Currency = (typeof Currency)[keyof typeof Currency];

class Account {
  readonly owner: string;
  private balance: number;

  constructor(owner: string, balance: number) {
    this.owner = owner;
    this.balance = balance;
  }

  describe(currency: Currency = Currency.NGN): string {
    return `${this.owner}: ${currency} ${this.balance}`;
  }
}

console.log(new Account("Ada", 5000).describe());
console.log(new Account("Bola", 120).describe(Currency.USD));
Output of npx tsx account.ts and of the browser terminal
Ada: NGN 5000
Bola: USD 120

Delete every type from this file and what is left is exactly the JavaScript that runs. Enums and their alternatives compares the two styles in detail.

Transpiling to older JavaScript

Removing types is one half of emitting. The other half is transpiling: translating code from one version of a language into another version of the same language (as opposed to compiling into a different, lower-level language). The target setting says which JavaScript version to produce.

With "target": "ES2024", which the course uses, Node.js 24 understands everything, so tsc leaves your syntax alone. Here is a function using optional chaining (?.) and nullish coalescing (??), both added to JavaScript in ES2020:

price.ts
type Order = { id: number; discount?: { percent: number } };

export function discountPercent(order: Order): number {
  return order.discount?.percent ?? 0;
}

console.log(discountPercent({ id: 1 }), discountPercent({ id: 2, discount: { percent: 15 } }));
Output of npx tsx price.ts and of the browser terminal
0 15

Ask for ES2019, a version from before those operators existed, and watch tsc rewrite them:

Terminal on your computer
npx tsc --noEmit false --outDir dist --target ES2019
dist/price.js
export function discountPercent(order) {
    var _a;
    var _b;
    return (_b = (_a = order.discount) === null || _a === void 0 ? void 0 : _a.percent) !== null && _b !== void 0 ? _b : 0;
}
console.log(discountPercent({ id: 1 }), discountPercent({ id: 2, discount: { percent: 15 } }));

Same behaviour, much uglier code. (void 0 is an old, safe way to write undefined.) Two things to know about target:

  • It rewrites syntax, never adds functions. If you call Array.prototype.findLast (ES2023), a lower target does not add it. Instead, the compiler reports that it does not exist, because target also sets which built-in functions it believes are there (the lib setting, covered in tsconfig in depth).
  • Match it to where the code runs. For a Node.js 24 backend, ES2024 is right: a lower target only makes the code bigger and slower to read in stack traces.

What else tsc can write

Besides .js files, two more outputs matter for real projects.

Declaration files (.d.ts)

When you publish a library, people who install it get JavaScript, which has no types. --declaration makes tsc also write a declaration file for each module: only the types, with no code bodies. Compile the transfer module with it:

Terminal on your computer
npx tsc --noEmit false --outDir dist --declaration
ls dist
receipt.d.ts  receipt.js  transfer.d.ts  transfer.js
dist/transfer.d.ts
export interface Transfer {
    from: string;
    to: string;
    amount: number;
}
export declare function transferFee(amount: number): number;

The function body is gone; only its signature is left, marked declare ("this exists somewhere, trust me"). This is exactly how every @zudojs/* package ships: JavaScript in .js, and types in .d.ts next to it, so your editor knows every function's parameters. The @types/node package you installed is nothing but .d.ts files describing Node.js.

Source maps (.js.map)

When compiled code throws, the stack trace points at the JavaScript file. That is not a file you ever edit. A source map is a file that maps each position in the output back to your .ts source. Here is a withdrawal that throws when the balance is too low:

withdraw.ts
interface Account {
  owner: string;
  balance: number;
}

function withdraw(account: Account, amount: number): Account {
  if (amount > account.balance) {
    throw new Error(`Insufficient funds: ${account.owner} has ${account.balance}`);
  }
  return { ...account, balance: account.balance - amount };
}

withdraw({ owner: "Ada", balance: 3000 }, 5000);

Ask tsc for a source map, and tell Node to use it:

Terminal on your computer
npx tsc --noEmit false --outDir dist --sourceMap
ls dist
withdraw.js  withdraw.js.map
node dist/withdraw.js
…
Error: Insufficient funds: Ada has 3000
    at withdraw (file:///home/you/ts-tasks/dist/withdraw.js:3:15)
node --enable-source-maps dist/withdraw.js
…
Error: Insufficient funds: Ada has 3000
    at withdraw (/home/you/ts-tasks/withdraw.ts:8:11)

With the map, the error points at line 8 of withdraw.ts, the line you actually wrote. Production servers usually run with --enable-source-maps for exactly this reason.

Checking only: --noEmit and watch mode

In most projects today, tsc never emits anything during development. Something faster runs the code (tsx, Node itself, or a bundler), and tsc --noEmit is used purely as the checker. That split is why the course's tsconfig.json has "noEmit": true.

Running the check by hand after every change is tedious. Watch mode keeps tsc running: it checks once, then waits, and re-checks every time you save a file. Because it remembers the previous result, each re-check only redoes the work that changed:

Terminal on your computer
npx tsc --noEmit --watch
[08:12:41 PM] Starting compilation in watch mode...

[08:12:41 PM] Found 0 errors. Watching for file changes.

# you add a line to transfer.ts and save it:
[08:12:43 PM] File change detected. Starting incremental compilation...

transfer.ts:13:14 - error TS2322: Type 'string' is not assignable to type 'number'.

13 export const flatFee: number = "25";
                ~~~~~~~


Found 1 error in transfer.ts:13

[08:12:43 PM] Found 1 error. Watching for file changes.

Stop it with Ctrl+C. A common setup is two terminals: one running the program in its own watch mode, one running tsc --noEmit --watch. Your editor gives you the same errors as squiggles, but only for the files you have open; tsc checks every file in the project.

Node.js type stripping and its limits

Node.js 24 runs .ts files directly. It does it by type stripping: it replaces every piece of type syntax with spaces, so line and column numbers stay the same, and runs what is left. It is quick, and there is no dist folder. It also has hard limits, and each one is a way to get bitten.

1. It never checks types

You saw it in the first section: node refund.ts printed 500050. Stripping only needs to know where the types are, not whether they are right.

2. Erasable syntax only

Node refuses anything that would need generated code, with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Turn on erasableSyntaxOnly in tsconfig.json and tsc reports these at check time instead of Node reporting them at startup.

3. A type-only import must say so

Node strips files one at a time. When it sees import { Transfer } from "./types.ts", it cannot know that Transfer is only an interface in the other file, so it keeps the import, and the program dies at startup:

Terminal on your computer
node main.ts
file:///home/you/ts-tasks/main.ts:1
import { Transfer } from "./types.ts";
         ^^^^^^^^
SyntaxError: The requested module './types.ts' does not provide an export named 'Transfer'

Writing import type { Transfer } tells every tool that the whole import can be deleted. The verbatimModuleSyntax setting from your tsconfig makes tsc insist on it (error TS1484), so this bug is caught by the checker before Node ever sees it. Modules in TypeScript covers import type fully.

4. Imports must name the real file

Node does not guess file names: the import must say ./fees.ts, the file that exists on disk. tsc refuses .ts in an import path unless you allow it, because the emitted JavaScript would then point at a .ts file. Two settings solve both sides: allowImportingTsExtensions lets you write .ts, and rewriteRelativeImportExtensions makes tsc turn ./fees.ts into ./fees.js when it does emit. Here is a tsconfig.json for a project that runs with plain node:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"],
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true
  }
}
fees.ts
export interface Transfer {
  from: string;
  to: string;
  amount: number;
}

export function transferFee(amount: number): number {
  if (amount <= 5000) return 10;
  if (amount <= 50000) return 25;
  return 50;
}
main.ts
import { transferFee, type Transfer } from "./fees.ts";

const transfer: Transfer = { from: "Ada", to: "Bola", amount: 20000 };
console.log(`${transfer.from} -> ${transfer.to}: fee ₦${transferFee(transfer.amount)}`);
Output of npx tsx main.ts and of the browser terminal
Ada -> Bola: fee ₦25
Terminal on your computer
npx tsc --noEmit
node main.ts
Ada -> Bola: fee ₦25

5. It ignores tsconfig.json, and node_modules

Node does not read tsconfig.json at all: paths, target and every other setting have no effect on node file.ts. And it refuses to strip .ts files inside node_modules, so a package must be published as JavaScript. That is one more reason libraries compile with tsc and ship .js plus .d.ts.

tsctsxnode file.ts
Checks typesYesNoNo
Enums, namespaces, parameter propertiesYesYesNo
Reads tsconfig.jsonYesPartly (for example paths)No
Import path for fees.ts./fees.js, or ./fees.ts with the settings aboveEither./fees.ts
Writes filesYes, unless noEmitNoNo

Package scripts that really check

The refund bug shipped because checking was something people could do, not something that had to happen. Package scripts fix that. You wrote a first set in Modules in TypeScript; here is one for a project that runs with Node's type stripping in development and ships compiled JavaScript:

package.json
{
  "name": "bank-transfers",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node --watch src/main.ts",
    "check": "tsc --noEmit",
    "check:watch": "tsc --noEmit --watch",
    "build": "tsc -p tsconfig.build.json",
    "test": "vitest run",
    "verify": "npm run check && npm test && npm run build",
    "start": "node --enable-source-maps dist/main.js"
  }
}
  • dev restarts the program whenever a file changes (node --watch). Fast, and it checks nothing.
  • check and check:watch are the only scripts that find type errors.
  • build uses a second config file, tsconfig.build.json, which extends the main one and switches noEmit off, with outDir, noEmitOnError and sourceMap on.
  • verify chains the steps with &&, which runs the next command only when the previous one exited with 0. A type error stops everything before the tests and the build.

Your CI (the server that runs checks on every push, which you will set up in Deployment) runs npm run verify. Now the type check is not a habit; it is a gate.

Why a program that compiles still needs runtime checks

REASON IT OUT

Before you trust a green check

Your transfer service passes npm run verify with zero errors. It reads a daily transfer limit from the environment and refuses transfers above it. Before you look at the code, think:

  1. What does tsc know about the value of an environment variable?
  2. If the limit is set to 5O000 (with a letter O) by mistake, what number does Number("5O000") give?
  3. What does amount > limit give when limit is that number?
  4. Which of those mistakes could the compiler possibly catch?
Show the reasoning
  1. Only its type: string | undefined. It cannot know the text, because that text is set on the server, long after compiling.
  2. NaN, "not a number". Its type is still number, so every annotation is satisfied.
  3. false, for every amount. Every comparison with NaN is false. So "is the amount above the limit?" says no to ₦10,000,000.
  4. None of them. The code is type-correct. The bug is in the data. Only a runtime check, written by you, can refuse a limit that is not a sensible positive number.

Here is that code. It compiles cleanly:

limit.ts
const env: Record<string, string | undefined> = { TRANSFER_LIMIT: "5O000" };

const limit: number = Number(env.TRANSFER_LIMIT);

function allowed(amount: number): boolean {
  return !(amount > limit);
}

console.log("limit:", limit);
console.log("₦10,000,000 allowed?", allowed(10_000_000));
Output of npx tsx limit.ts and of the browser terminal
limit: NaN
₦10,000,000 allowed? true

(The env object stands in for process.env, which has the same type, so the example also runs in the browser terminal.) Written as !(amount > limit), "not above the limit", the check fails open: when the limit is broken, everything is allowed. Types could not help, because NaN is a perfectly good number.

The fix is a runtime check at the place where outside data enters, which stops the program loudly instead of running with nonsense:

limit.ts
function readLimit(raw: string | undefined): number {
  const value = Number(raw);
  if (raw === undefined || !Number.isInteger(value) || value <= 0) {
    throw new Error(`TRANSFER_LIMIT must be a positive whole number, got ${JSON.stringify(raw)}`);
  }
  return value;
}

for (const raw of ["50000", "5O000", undefined, "-1"]) {
  try {
    console.log("limit:", readLimit(raw));
  } catch (error) {
    console.log(String(error));
  }
}
Output of npx tsx limit.ts and of the browser terminal
limit: 50000
Error: TRANSFER_LIMIT must be a positive whole number, got "5O000"
Error: TRANSFER_LIMIT must be a positive whole number, got undefined
Error: TRANSFER_LIMIT must be a positive whole number, got "-1"

This is the general rule for the rest of the course: types check your code; runtime checks check your data. Environment variables, request bodies, database rows, files and replies from other services are all data. TypeScript and JavaScript together and Runtime validation build proper tools for it.

Testing your types

Tests usually run code and compare results. Types need a different kind of test: "this call must not compile". The // @ts-expect-error comment does that. It tells tsc that the next line must contain a type error. If it does, the error is silenced. If it does not, that is reported as an error:

fee.types.ts
function transferFee(amount: number): number {
  if (amount <= 5000) return 10;
  if (amount <= 50000) return 25;
  return 50;
}

// @ts-expect-error: a fee is worked out from a number, never from text
transferFee("2500");

// @ts-expect-error: the amount is required
transferFee();

// @ts-expect-error: the result is a number, not a string
const label: string = transferFee(2500) + " naira";
What npx tsc --noEmit prints
fee.types.ts:13:1 - error TS2578: Unused '@ts-expect-error' directive.

13 // @ts-expect-error: the result is a number, not a string
   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


Found 1 error in fee.types.ts:13

The first two expectations hold, so they are silent. The third is wrong: number + " naira" is a string, so the line compiles, and tsc reports that the directive is unused. Delete that test (it tested the wrong thing), and the file passes. Files like this, run by npm run check, stop someone from quietly loosening a type, for example changing amount: number to amount: any.

EXPECT-ERROR, NOT IGNORE

There is also // @ts-ignore, which silences the next line's errors whether there are any or not, and keeps silencing them forever. Use @ts-expect-error in type tests, and neither in normal code.

In production

  • Check in CI, every time. tsc --noEmit is the only step that finds type errors. Make it a required step, as in the verify script.
  • Build once, run JavaScript. Compile in CI with noEmitOnError, and deploy the dist folder. The server then runs exactly the files that passed the checks, and nothing has to strip types while it starts.
  • Keep source maps on. Emit .js.map files and start Node with --enable-source-maps, so stack traces in your logs point at .ts lines.
  • Prefer erasable syntax. erasableSyntaxOnly keeps every runner (tsc, tsx, Node, bundlers) producing the same code.
  • Speed matters less than it did. TypeScript 7's native compiler checks large projects many times faster than earlier versions, so "the check is too slow for CI" is rarely true any more.
  • Validate at the edges. A green check says your code is consistent with itself. It says nothing about the data that will arrive.

Practice

TRY IT YOURSELF

Predict the emitted JavaScript

Without running anything, write down what tsc emits for this file with "target": "ES2024". Then compile it and compare.

fees.ts
export interface Transfer {
  from: string;
  to: string;
  amount: number;
}
booking.ts
import type { Transfer } from "./fees.js";

interface Booking {
  readonly id: number;
  guest: string;
  nights: number;
}

const RATE = 25000;

export function quote(booking: Booking, extras: number[] = []): number {
  const base = booking.nights * RATE;
  return extras.reduce<number>((sum, x) => sum + x, base);
}

console.log(quote({ id: 1, guest: "Ada", nights: 2 }, [5000]) as number);
Output of npx tsx booking.ts and of the browser terminal
55000
Show a solution
dist/booking.js
const RATE = 25000;
export function quote(booking, extras = []) {
    const base = booking.nights * RATE;
    return extras.reduce((sum, x) => sum + x, base);
}
console.log(quote({ id: 1, guest: "Ada", nights: 2 }, [5000]));

The whole import type line goes, and so do the interface (including readonly), the annotations, the <number> type argument on reduce, and as number. The default = [] stays, because it is JavaScript. Nothing is added, because nothing in the file needs generated code.

TRY IT YOURSELF

Make it run under node file.ts

This file works with tsx but not with node, and erasableSyntaxOnly reports three errors in it. Rewrite it so that it is erasable and prints the same line.

enum Status { Pending = "pending", Settled = "settled" }

class Ledger {
  constructor(private readonly owner: string, private status: Status = Status.Pending) {}
  settle(): string {
    this.status = Status.Settled;
    return `${this.owner}: ${this.status}`;
  }
}

console.log(new Ledger("Ada").settle());
Show a solution
ledger.ts
const Status = { Pending: "pending", Settled: "settled" } as const;
type Status = (typeof Status)[keyof typeof Status];

class Ledger {
  private readonly owner: string;
  private status: Status;

  constructor(owner: string, status: Status = Status.Pending) {
    this.owner = owner;
    this.status = status;
  }

  settle(): string {
    this.status = Status.Settled;
    return `${this.owner}: ${this.status}`;
  }
}

console.log(new Ledger("Ada").settle());
Output of npx tsx ledger.ts and of the browser terminal
Ada: settled

The enum becomes a const object plus a type with the same name (a value and a type may share a name, because they live in different worlds). The two parameter properties become declared fields assigned in the constructor. A simple union, type Status = "pending" | "settled", would also work if you do not need Status.Pending as a value.

TRY IT YOURSELF

A config reader that fails closed

Write readConfig(env) that takes a Record<string, string | undefined> and returns { limit: number; feePercent: number }. TRANSFER_LIMIT must be a positive whole number. FEE_PERCENT is optional (default 0.5) and must be between 0 and 5. Collect every problem and throw one error listing them all.

Show a solution
config.ts
interface Config {
  limit: number;
  feePercent: number;
}

function readConfig(env: Record<string, string | undefined>): Config {
  const problems: string[] = [];
  const limit = Number(env.TRANSFER_LIMIT);
  if (!Number.isInteger(limit) || limit <= 0) problems.push("TRANSFER_LIMIT must be a positive whole number");
  const feePercent = env.FEE_PERCENT === undefined ? 0.5 : Number(env.FEE_PERCENT);
  if (Number.isNaN(feePercent) || feePercent < 0 || feePercent > 5) problems.push("FEE_PERCENT must be between 0 and 5");
  if (problems.length > 0) throw new Error(problems.join("; "));
  return { limit, feePercent };
}

console.log(readConfig({ TRANSFER_LIMIT: "50000" }));
try {
  readConfig({ TRANSFER_LIMIT: "5O000", FEE_PERCENT: "12" });
} catch (error) {
  console.log(String(error));
}
Output of npx tsx config.ts and of the browser terminal
{ limit: 50000, feePercent: 0.5 }
Error: TRANSFER_LIMIT must be a positive whole number; FEE_PERCENT must be between 0 and 5

Number(undefined) is NaN, so a missing limit fails the isInteger check without a separate test. Reporting every problem at once saves the person deploying from fixing one variable, restarting, and finding the next.

Recap

  • TypeScript is a superset of JavaScript's syntax. Valid JavaScript can still fail the type check.
  • tsc parses, checks and emits. Emitting does not depend on checking: without noEmitOnError, broken code is still written (exit code 2).
  • Type erasure removes interfaces, type aliases, annotations, generics, as, !, satisfies and import type. None of them check anything at runtime. Values can produce types; types cannot produce values.
  • enum, namespace and parameter properties generate code. erasableSyntaxOnly bans them.
  • target transpiles syntax to older JavaScript; declaration writes .d.ts; sourceMap maps stack traces back to .ts; --watch re-checks on save.
  • node file.ts strips types without checking, needs erasable syntax, import type and real .ts paths, and ignores tsconfig.json.
  • Put tsc --noEmit in a script that CI must pass. And validate outside data at runtime, because a compiled program knows nothing about it.

Next: Basic types, the everyday types you will write in every file.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.