Emmanuel Akinfulubi

Playground

Unlike the other two courses, this one cannot give you an editor that checks your work. Here is why, and here is what to do instead — which happens to be the workflow you would use on a real project anyway.

The workflow instead

Three commands, and you have everything the panels have plus real checking.

The whole setup

# 1. Get the compiler. Once, in whatever folder you are working in.
npm install --save-dev typescript

# 2. Run a file. Node strips the types and executes it.
#    Fast, no configuration, and it checks NOTHING.
node scratch.ts

# 3. Check a file. This is the one that finds your mistakes.
npx tsc --noEmit scratch.ts

# Or check continuously, in a second terminal, while you work.
npx tsc --noEmit --watch

Your editor is the real playground

This is not a consolation prize. An editor with TypeScript support gives you things no web page can: errors as you type, hover to see any inferred type, jump to a definition, rename across a project, and autocomplete that is actually correct. The hover in particular is the single best learning tool available — when a lesson says a type is inferred as something, hovering it is how you confirm that yourself.

Any editor with a language-server integration will do. The compiler you installed above is what powers it.

Starters

Copy any of these into a file and go. The first two are the ones to set up now; the rest map onto specific lessons.

Jump to

Scratch file

scratch.ts

The file to keep open while you read. Every lesson's exercises are meant for it.

scratch.ts

// scratch.ts — run with:  node scratch.ts
//              check with: npx tsc --noEmit scratch.ts

interface User {
  id: number;
  name: string;
  email?: string;
}

function describe(user: User): string {
  return `#${user.id} ${user.name} <${user.email ?? 'no email'}>`;
}

const ada: User = { id: 1, name: 'Ada' };
console.log(describe(ada));

// Try each of these, one at a time, and read what the checker says.
//
//   describe({ id: 2 });                  missing 'name'
//   describe({ id: 2, name: 'G', x: 1 }); excess property
//   ada.email.toUpperCase();              possibly undefined
//   ada.id = 'one';                       wrong type

Strict config

tsconfig.json

A tsconfig.json matching this course, so your file is checked the same way the samples were.

tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "target": "es2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "lib": ["es2023", "dom"],

    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,

    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}

Narrowing drills

narrowing.ts

Every check the compiler understands, in one file. Delete a guard and watch what breaks.

narrowing.ts

// narrowing.ts

type Value = string | number | boolean | null | undefined | Date | string[];

function describe(value: Value): string {
  if (value === null) return 'null';
  if (value === undefined) return 'undefined';
  if (typeof value === 'string') return `string(${value.length})`;
  if (typeof value === 'number') return `number(${value.toFixed(1)})`;
  if (typeof value === 'boolean') return `boolean(${value})`;
  if (Array.isArray(value)) return `array(${value.length})`;
  if (value instanceof Date) return `date(${value.getUTCFullYear()})`;
  return assertNever(value);
}

function assertNever(value: never): never {
  throw new Error(`unhandled: ${JSON.stringify(value)}`);
}

const inputs: Value[] = ['ab', 3.14159, true, null, undefined, new Date('2026-01-01'), ['x', 'y']];
for (const input of inputs) console.log(describe(input));

// Exercises:
//   1. Delete the Array.isArray check. Read the error on assertNever.
//   2. Add Map<string, number> to Value and see where the compiler sends you.
//   3. Replace 'value === null' with truthiness and work out what breaks for 0.

Validating input

boundary.ts

The pattern from lesson 13: turn unknown data into a typed value once, at the edge.

boundary.ts

// boundary.ts

interface Order {
  id: string;
  total: number;
  lines: { sku: string; quantity: number }[];
}

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isLine(value: unknown): value is { sku: string; quantity: number } {
  return isObject(value)
    && typeof value['sku'] === 'string'
    && typeof value['quantity'] === 'number';
}

function isOrder(value: unknown): value is Order {
  return isObject(value)
    && typeof value['id'] === 'string'
    && typeof value['total'] === 'number'
    && Array.isArray(value['lines'])
    && value['lines'].every(isLine);
}

const inputs: string[] = [
  '{"id":"A-1","total":340,"lines":[{"sku":"desk","quantity":1}]}',
  '{"id":"A-2","total":"lots","lines":[]}',
  'null',
];

for (const json of inputs) {
  const parsed: unknown = JSON.parse(json);
  if (isOrder(parsed)) {
    const units = parsed.lines.reduce((n, line) => n + line.quantity, 0);
    console.log(`${parsed.id}: ${units} units, £${parsed.total}`);
  } else {
    console.log(`rejected: ${json.slice(0, 40)}`);
  }
}

// Exercise: add a required 'placedAt' string and confirm the second input is
// still rejected for the right reason.

Generics practice

generics.ts

A generic container plus the get/set pair from lesson 10, which is the shape worth memorising.

generics.ts

// generics.ts

// The shape to know by heart: one type parameter constrained by another, so
// the return type depends on the value of an argument.
function get<T, K extends keyof T>(object: T, key: K): T[K] {
  return object[key];
}

function set<T, K extends keyof T>(object: T, key: K, value: T[K]): T {
  return { ...object, [key]: value };
}

interface Settings {
  theme: 'light' | 'dark';
  fontSize: number;
  wrap: boolean;
}

const settings: Settings = { theme: 'light', fontSize: 14, wrap: true };

console.log(get(settings, 'theme'));
console.log(get(settings, 'fontSize').toFixed(0));
console.log(set(settings, 'theme', 'dark'));

// Exercises:
//   1. get(settings, 'font');              a key that does not exist
//   2. set(settings, 'fontSize', 'large'); a value that does not match the key
//   3. Write pick(object, keys) returning Pick<T, K>. You will need K[] and
//      a loop; the signature is the hard part, not the body.

Migrating a file

legacy.js

A JavaScript file with @ts-check and JSDoc types, which is the real intermediate step.

legacy.js

// legacy.js — still JavaScript, and now checked.
// @ts-check

/**
 * @param {string} name
 * @param {number} [times]
 * @returns {string}
 */
function repeat(name, times = 1) {
  return name.repeat(times);
}

/**
 * @typedef {object} Point
 * @property {number} x
 * @property {number} y
 */

/**
 * @param {Point} a
 * @param {Point} b
 * @returns {number}
 */
function distance(a, b) {
  return Math.hypot(a.x - b.x, a.y - b.y);
}

console.log(repeat('ab', 3));
console.log(distance({ x: 0, y: 0 }, { x: 3, y: 4 }));

// Exercises:
//   1. Call repeat(3, 'ab') and confirm the JSDoc types are enforced.
//   2. Call distance({ x: 0 }, { x: 3, y: 4 }) and read the error.
//   3. Rename this file to .ts and convert the JSDoc to annotations. Notice
//      how much shorter it gets — that is the argument for finishing the move.

A note on the online TypeScript playground

There is an official web playground, and it is genuinely excellent — it runs the real compiler in the browser, shows the emitted JavaScript and the type errors side by side, and lets you toggle every compiler flag to see what changes. It is the right tool for testing a snippet or sharing one.

This course does not embed it for the same reason it does not embed a compiler: it requires a network, and everything here is meant to work from a file with no connection. If you are online, it is worth having open alongside these pages, particularly while reading lesson 17, where seeing a computed type resolve step by step is worth a great deal.

Exercises, in one place

Every lesson ends with a numbered list meant for a scratch file. If you would rather work through them in a batch than lesson by lesson, they are all in the lessons themselves — but the six most useful, if you only do six:

  1. Lesson 01 — run a file with a type error under node, then under tsc --noEmit. This is the whole mental model in two commands.
  2. Lesson 02 — take an over-annotated file and delete every annotation that earns nothing.
  3. Lesson 05 — reproduce the callback trap, then fix it by copying to a const.
  4. Lesson 08 — write a tree traversal, omit the base case, read the error.
  5. Lesson 10 — write get<T, K extends keyof T> from memory.
  6. Lesson 18 — add // @ts-check to a real JavaScript file of yours and fix what appears.

What is stored. Your progress through the lessons lives in this browser's localStorage under the ts. prefix, and nowhere else. This page stores nothing at all, because there is nothing here to store.

Why the panels here have no Run button. They are files to copy out rather than programs to execute in a page. Four of the six — scratch.ts, narrowing.ts, boundary.ts and generics.ts — still went through the real compiler when this page was built, so they are guaranteed to compile cleanly under the config above before you paste them anywhere. The other two could not be: one is a tsconfig.json and the other is JavaScript, and a TypeScript-only pipeline has nothing to say about either.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.