Skip to content
aviral gupta

// B5.5 · ~38 min · Beginner

Object types together; build: a typed inventory loader

After this lesson you can model nested data with every object type tool of this module, load it safely from JSON, and compute summaries without changing it.

Lesson 5 of 5 in B5 Object types

End of the module

You will be able to

  • Model nested data with optional and readonly properties, an index signature, an extended interface, readonly arrays and a tuple
  • Turn an unknown value from JSON.parse into a typed object with hand-written checks and type predicates
  • Compute summaries over readonly data without changing it, and test them with node:test
  1. Warm-up · Activity 1 of 7

    Warm-up from JavaScript: JSON.parse gives you whatever the text says. What does this print?

    const data = JSON.parse('{ "price": "25" }');
    console.log(data.price * 2, typeof data.price);
  2. Predict · Activity 2 of 7

    Predict before you read on: the JSON has a number where Item has a string. What happens when you run npx tsc, then node main.ts?

    interface Item {
      name: string;
    }
    
    const raw = JSON.parse('{ "items": [{ "name": 42 }] }');
    const items: Item[] = raw.items;
    console.log(items[0].name.toUpperCase());
  3. Practice · Activity 3 of 7

    Fill in the type for raw, so that tsc refuses raw.items until it is checked: the // @ts-expect-error line expects that error.

    interface Inventory {
      items: string[];
    }
    
    const raw: ____ = JSON.parse('{ "items": ["lamp"] }');
    // @ts-expect-error: check raw before you use it
    console.log(raw.items);
    const raw: = JSON.parse('{ "items": ["lamp"] }');
  4. Practice · Activity 4 of 7

    value starts as unknown. Which type does it have at the comment?

    function tagsOf(value: unknown) {
      if (Array.isArray(value)) {
        // here
        return value;
      }
      return [];
    }
  5. Practice · Activity 5 of 7

    The model of one item. Which lines does tsc reject?

    interface Product {
      readonly sku: string;
      name: string;
      note?: string;
    }
    interface Item extends Product {
      tags: readonly string[];
      location: readonly [string, number];
    }
    const lamp: Item = { sku: "L-1", name: "Lamp", tags: ["light"], location: ["A", 3] };
    lamp.name = "Desk lamp";
    lamp.note = "new";
    lamp.tags = [...lamp.tags, "sale"];
    lamp.location[1] = 4;
    lamp.sku = "L-2";
  6. Brain teaser · Activity 6 of 7

    Brain teaser. isRecord forgets one case. What happens when you run npx tsc, then node main.ts?

    function isRecord(value: unknown): value is { [key: string]: unknown } {
      return typeof value === "object";
    }
    
    function skuOf(value: unknown): string {
      if (!isRecord(value)) throw new Error("not an object");
      const { sku } = value;
      return typeof sku === "string" ? sku : "no sku";
    }
    
    console.log(skuOf(JSON.parse("null")));
  7. Apply · Activity 7 of 7

    Mini-task. Write a Product with a readonly sku, a price and an optional discount (a number). Write parseProduct(value: unknown), which checks the object and its fields with typeof and a fail(message): never helper. A missing discount is fine; any other non-number fails with discount must be a number. Load one product with a discount of 5 and one with "5%". Check it in the editor, or with npx tsc and node main.ts.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

An inventory loader

The whole module in one program. DATA is the JSON text. The model uses every tool of B5. The predicates and parseItem check an unknown value field by field; parseInventory checks the outer object and its items array. totalStock and the value read the data and change nothing. The last call loads an item whose price is "cheap". Run npx tsc, then node main.ts. Then change "B" in DATA to 2 and run it again.

main.ts

// The data, as it might arrive from a file or a server.
const DATA = `{
  "updated": "2026-10-04",
  "items": [
    { "sku": "L-1", "name": "Desk lamp", "price": 25, "location": ["A", 3],
      "tags": ["light"], "stock": { "berlin": 4, "bonn": 2 } },
    { "sku": "C-7", "name": "Chair", "price": 80, "note": "assembly needed",
      "location": ["B", 1], "tags": ["office", "seating"], "stock": { "berlin": 1 } }
  ]
}`;

// The model: every idea from module B5.
interface Product {
  readonly sku: string;
  name: string;
  price: number;
  note?: string;
}
interface Stock {
  readonly [warehouse: string]: number;
}
type Location = readonly [string, number]; // aisle, shelf
interface Item extends Product {
  location: Location;
  tags: readonly string[];
  stock: Stock;
}
interface Inventory {
  readonly updated: string;
  readonly items: readonly Item[];
}

// Checks: from unknown to the model, one property at a time.
function fail(message: string): never {
  throw new Error(message);
}
function isRecord(value: unknown): value is { [key: string]: unknown } {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isLocation(value: unknown): value is Location {
  return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number";
}
function isStringArray(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((v) => typeof v === "string");
}
function isStock(value: unknown): value is Stock {
  return isRecord(value) && Object.values(value).every((n) => typeof n === "number");
}

function parseItem(value: unknown): Item {
  if (!isRecord(value)) fail("an item must be an object");
  const { sku, name, price, note, location, tags, stock } = value;
  if (typeof sku !== "string") fail("sku must be a string");
  if (typeof name !== "string") fail("name must be a string");
  if (typeof price !== "number") fail("price must be a number");
  if (!isLocation(location)) fail("location must be [aisle, shelf]");
  if (!isStringArray(tags)) fail("tags must be strings");
  if (!isStock(stock)) fail("stock must map warehouses to numbers");
  const item: Item = { sku, name, price, location, tags, stock };
  if (note !== undefined) {
    if (typeof note !== "string") fail("note must be a string");
    item.note = note;
  }
  return item;
}

function parseInventory(text: string): Inventory {
  const raw: unknown = JSON.parse(text);
  if (!isRecord(raw)) fail("the inventory must be an object");
  const { updated, items } = raw;
  if (typeof updated !== "string") fail("updated must be a string");
  if (!Array.isArray(items)) fail("items must be an array");
  return { updated, items: items.map(parseItem) };
}

// Summaries: read the data, never change it.
function totalStock(item: Item): number {
  let total = 0;
  for (const count of Object.values(item.stock)) total += count;
  return total;
}

const inventory = parseInventory(DATA);
for (const item of inventory.items) {
  const [aisle, shelf] = item.location;
  console.log(item.sku + " " + item.name + ": " + totalStock(item) + " in stock, aisle " + aisle + " shelf " + shelf + (item.note ? " (" + item.note + ")" : ""));
}
let value = 0;
for (const item of inventory.items) value += item.price * totalStock(item);
console.log("value: " + value + ", updated " + inventory.updated);

try {
  parseInventory('{ "updated": "2026-10-04", "items": [{ "sku": "X-1", "name": "Shelf", "price": "cheap" }] }');
} catch (error) {
  console.log("rejected: " + (error instanceof Error ? error.message : String(error)));
}

Run it with

npx tsc
node main.ts

Output

L-1 Desk lamp: 6 in stock, aisle A shelf 3
C-7 Chair: 1 in stock, aisle B shelf 1 (assembly needed)
value: 230, updated 2026-10-04
rejected: price must be a number
  • note is set only when the item has one, so the lamp has no note property at all.
  • Object.values(item.stock) is a number[], so the loop adds numbers.
  • items.map(parseItem) checks each item; the first bad one stops the load with its message.
  • With "B" changed to 2, the first parseInventory stops with Error: location must be [aisle, shelf] before anything is printed: the chair fails isLocation.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 3

Step 1: model an item

Fix the model, so that lamp fits it and every // @ts-expect-error in noEdits finds its error. note becomes optional. stock becomes an exported interface Stock with a readonly index signature: warehouse names to counts. tags becomes a readonly string[], and location a readonly tuple of aisle and shelf. Then make totalStock add the counts of all warehouses, so that the lamp's total is 6. Run the tests: the first one is the type-check.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    export interface Stock { readonly [warehouse: string]: number; } and then stock: Stock in Item.

  2. Hint 2

    readonly goes in front of both array types: tags: readonly string[]; location: readonly [string, number];

  3. Hint 3

    let total = 0; for (const count of Object.values(item.stock)) total += count; return total;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export interface Product {
  readonly sku: string;
  name: string;
  price: number;
  note?: string;
}

export interface Stock {
  readonly [warehouse: string]: number;
}

export interface Item extends Product {
  location: readonly [string, number];
  tags: readonly string[];
  stock: Stock;
}

export function totalStock(item: Item): number {
  let total = 0;
  for (const count of Object.values(item.stock)) total += count;
  return total;
}

export function stockValue(items: readonly Item[]): number {
  let value = 0;
  for (const item of items) value += item.price * totalStock(item);
  return value;
}

export const lamp: Item = {
  sku: "L-1",
  name: "Desk lamp",
  price: 25,
  location: ["A", 3],
  tags: ["light"],
  stock: { berlin: 4, bonn: 2 }
};

function noEdits(item: Item) {
  // @ts-expect-error: tags are read-only
  item.tags.push("sale");
  // @ts-expect-error: the stock is read-only
  item.stock.berlin = 0;
  // @ts-expect-error: a location cannot change
  item.location[1] = 4;
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export interface Product {
  readonly sku: string;
  name: string;
  price: number;
  note: string;
}

export interface Item extends Product {
  location: [string, number];
  tags: string[];
  stock: number;
}

export function totalStock(item: Item): number {
  return item.stock;
}

export function stockValue(items: readonly Item[]): number {
  let value = 0;
  for (const item of items) value += item.price * totalStock(item);
  return value;
}

export const lamp: Item = {
  sku: "L-1",
  name: "Desk lamp",
  price: 25,
  location: ["A", 3],
  tags: ["light"],
  stock: { berlin: 4, bonn: 2 }
};

function noEdits(item: Item) {
  // @ts-expect-error: tags are read-only
  item.tags.push("sale");
  // @ts-expect-error: the stock is read-only
  item.stock.berlin = 0;
  // @ts-expect-error: a location cannot change
  item.location[1] = 4;
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {totalStock, stockValue, lamp} from './main.ts';

const chair = {sku: 'C-7', name: 'Chair', price: 80, location: ['B', 1] as const, tags: [], stock: {berlin: 1}};
const empty = {sku: 'E-0', name: 'Empty box', price: 2, location: ['C', 2] as const, tags: [], stock: {}};

test('totalStock adds the counts of every warehouse', () => {
  assert.equal(totalStock(lamp), 6, `totalStock(lamp) gave ${totalStock(lamp)}`);
});

test('an item with no stock entries has 0 in stock', () => {
  assert.equal(totalStock(empty), 0, `totalStock(empty) gave ${totalStock(empty)}`);
});

test('stockValue adds price times stock over all items', () => {
  const got = stockValue([lamp, chair, empty]);
  assert.equal(got, 230, `stockValue([lamp, chair, empty]) gave ${got}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 2 of 3

Step 2: check an item from unknown

parseItem checks sku, name and price, but isLocation and isStock check too little and return a plain boolean, so tsc cannot narrow location and stock, and tags is not checked at all. Turn both into type predicates. isLocation: exactly two elements, a string and a number. isStock: a record whose values are all numbers. Add a predicate for tags, an array of strings, and fail with tags must be strings when it does not hold.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    A predicate is a return type: function isLocation(value: unknown): value is Location {

  2. Hint 2

    Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number"

  3. Hint 3

    isRecord(value) && Object.values(value).every((count) => typeof count === "number") checks every warehouse.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export interface Product {
  readonly sku: string;
  name: string;
  price: number;
  note?: string;
}

export interface Stock {
  readonly [warehouse: string]: number;
}

export type Location = readonly [string, number];

export interface Item extends Product {
  location: Location;
  tags: readonly string[];
  stock: Stock;
}

function fail(message: string): never {
  throw new Error(message);
}

function isRecord(value: unknown): value is { [key: string]: unknown } {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function isLocation(value: unknown): value is Location {
  return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number";
}

export function isStock(value: unknown): value is Stock {
  return isRecord(value) && Object.values(value).every((count) => typeof count === "number");
}

function isStringArray(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((tag) => typeof tag === "string");
}

export function parseItem(value: unknown): Item {
  if (!isRecord(value)) fail("an item must be an object");
  const { sku, name, price, location, tags, stock } = value;
  if (typeof sku !== "string") fail("sku must be a string");
  if (typeof name !== "string") fail("name must be a string");
  if (typeof price !== "number") fail("price must be a number");
  if (!isLocation(location)) fail("location must be [aisle, shelf]");
  if (!isStringArray(tags)) fail("tags must be strings");
  if (!isStock(stock)) fail("stock must map warehouses to numbers");
  return { sku, name, price, location, tags, stock };
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export interface Product {
  readonly sku: string;
  name: string;
  price: number;
  note?: string;
}

export interface Stock {
  readonly [warehouse: string]: number;
}

export type Location = readonly [string, number];

export interface Item extends Product {
  location: Location;
  tags: readonly string[];
  stock: Stock;
}

function fail(message: string): never {
  throw new Error(message);
}

function isRecord(value: unknown): value is { [key: string]: unknown } {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function isLocation(value: unknown): boolean {
  return Array.isArray(value);
}

export function isStock(value: unknown): boolean {
  return isRecord(value);
}

export function parseItem(value: unknown): Item {
  if (!isRecord(value)) fail("an item must be an object");
  const { sku, name, price, location, tags, stock } = value;
  if (typeof sku !== "string") fail("sku must be a string");
  if (typeof name !== "string") fail("name must be a string");
  if (typeof price !== "number") fail("price must be a number");
  if (!isLocation(location)) fail("location must be [aisle, shelf]");
  if (!isStock(stock)) fail("stock must map warehouses to numbers");
  return { sku, name, price, location, tags, stock };
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {isLocation, isStock, parseItem} from './main.ts';

const lamp = {sku: 'L-1', name: 'Desk lamp', price: 25, location: ['A', 3], tags: ['light'], stock: {berlin: 4, bonn: 2}};

test('isLocation accepts an aisle and a shelf, and nothing else', () => {
  assert.equal(isLocation(['A', 3]), true, "isLocation(['A', 3]) should be true");
  assert.equal(isLocation(['A', '3']), false, "isLocation(['A', '3']) should be false: the shelf is a number");
  assert.equal(isLocation(['A', 3, 1]), false, "isLocation(['A', 3, 1]) should be false: exactly two parts");
});

test('isStock accepts only numbers per warehouse', () => {
  assert.equal(isStock({berlin: 4}), true, 'isStock({berlin: 4}) should be true');
  assert.equal(isStock({berlin: 'four'}), false, "isStock({berlin: 'four'}) should be false");
  assert.equal(isStock([4]), false, 'isStock([4]) should be false: an array is not a map of warehouses');
});

test('a valid item comes back with all its properties', () => {
  assert.deepEqual(parseItem(lamp), lamp, 'parseItem(lamp) should return an object equal to lamp');
});

test('tags must be an array of strings', () => {
  assert.throws(() => parseItem({...lamp, tags: ['light', 7]}), {message: 'tags must be strings'}, 'a tag 7 should throw "tags must be strings"');
});

test('a bad location or stock is rejected with its message', () => {
  assert.throws(() => parseItem({...lamp, location: ['A']}), {message: 'location must be [aisle, shelf]'}, "location ['A'] should throw \"location must be [aisle, shelf]\"");
  assert.throws(() => parseItem({...lamp, stock: {berlin: '4'}}), {message: 'stock must map warehouses to numbers'}, "stock {berlin: '4'} should throw \"stock must map warehouses to numbers\"");
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 3 of 3

Step 3: load the inventory and list low stock

parseInventory trusts JSON.parse, and lowStock sorts the readonly items in place, which tsc rejects with error TS2339. Make parseInventory read the text as unknown and check it: fail with the inventory must be an object, updated must be a string or items must be an array, and check every item with parseItem. lowStock returns the SKUs whose total stock is at or below the limit, sorted, without changing the order of the items.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    const raw: unknown = JSON.parse(text); then if (!isRecord(raw)) fail("the inventory must be an object");

  2. Hint 2

    After if (!Array.isArray(items)) fail(…), items.map(parseItem) checks each item and gives an Item[].

  3. Hint 3

    toSorted returns a sorted copy and leaves inventory.items alone.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export interface Product {
  readonly sku: string;
  name: string;
  price: number;
  note?: string;
}

export interface Stock {
  readonly [warehouse: string]: number;
}

export type Location = readonly [string, number];

export interface Item extends Product {
  location: Location;
  tags: readonly string[];
  stock: Stock;
}

export interface Inventory {
  readonly updated: string;
  readonly items: readonly Item[];
}

function fail(message: string): never {
  throw new Error(message);
}

function isRecord(value: unknown): value is { [key: string]: unknown } {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isLocation(value: unknown): value is Location {
  return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number";
}

function isStock(value: unknown): value is Stock {
  return isRecord(value) && Object.values(value).every((count) => typeof count === "number");
}

function isStringArray(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((tag) => typeof tag === "string");
}

export function parseItem(value: unknown): Item {
  if (!isRecord(value)) fail("an item must be an object");
  const { sku, name, price, location, tags, stock } = value;
  if (typeof sku !== "string") fail("sku must be a string");
  if (typeof name !== "string") fail("name must be a string");
  if (typeof price !== "number") fail("price must be a number");
  if (!isLocation(location)) fail("location must be [aisle, shelf]");
  if (!isStringArray(tags)) fail("tags must be strings");
  if (!isStock(stock)) fail("stock must map warehouses to numbers");
  return { sku, name, price, location, tags, stock };
}

export function totalStock(item: Item): number {
  let total = 0;
  for (const count of Object.values(item.stock)) total += count;
  return total;
}

export function parseInventory(text: string): Inventory {
  const raw: unknown = JSON.parse(text);
  if (!isRecord(raw)) fail("the inventory must be an object");
  const { updated, items } = raw;
  if (typeof updated !== "string") fail("updated must be a string");
  if (!Array.isArray(items)) fail("items must be an array");
  return { updated, items: items.map(parseItem) };
}

export function lowStock(inventory: Inventory, limit: number): readonly string[] {
  const sorted = inventory.items.toSorted((a, b) => a.sku.localeCompare(b.sku));
  return sorted.filter((item) => totalStock(item) <= limit).map((item) => item.sku);
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export interface Product {
  readonly sku: string;
  name: string;
  price: number;
  note?: string;
}

export interface Stock {
  readonly [warehouse: string]: number;
}

export type Location = readonly [string, number];

export interface Item extends Product {
  location: Location;
  tags: readonly string[];
  stock: Stock;
}

export interface Inventory {
  readonly updated: string;
  readonly items: readonly Item[];
}

function fail(message: string): never {
  throw new Error(message);
}

function isRecord(value: unknown): value is { [key: string]: unknown } {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isLocation(value: unknown): value is Location {
  return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number";
}

function isStock(value: unknown): value is Stock {
  return isRecord(value) && Object.values(value).every((count) => typeof count === "number");
}

function isStringArray(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((tag) => typeof tag === "string");
}

export function parseItem(value: unknown): Item {
  if (!isRecord(value)) fail("an item must be an object");
  const { sku, name, price, location, tags, stock } = value;
  if (typeof sku !== "string") fail("sku must be a string");
  if (typeof name !== "string") fail("name must be a string");
  if (typeof price !== "number") fail("price must be a number");
  if (!isLocation(location)) fail("location must be [aisle, shelf]");
  if (!isStringArray(tags)) fail("tags must be strings");
  if (!isStock(stock)) fail("stock must map warehouses to numbers");
  return { sku, name, price, location, tags, stock };
}

export function totalStock(item: Item): number {
  let total = 0;
  for (const count of Object.values(item.stock)) total += count;
  return total;
}

export function parseInventory(text: string): Inventory {
  const raw = JSON.parse(text);
  return { updated: raw.updated, items: raw.items };
}

export function lowStock(inventory: Inventory, limit: number): readonly string[] {
  const sorted = inventory.items.sort((a, b) => a.sku.localeCompare(b.sku));
  return sorted.filter((item) => totalStock(item) <= limit).map((item) => item.sku);
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseInventory, lowStock} from './main.ts';

const item = (sku, berlin) => ({sku, name: sku, price: 1, location: ['A', 1], tags: [], stock: {berlin}});
const text = JSON.stringify({updated: '2026-10-04', items: [item('L-1', 6), item('C-7', 1), item('B-2', 3)]});

test('a valid inventory is loaded with all its items', () => {
  const inventory = parseInventory(text);
  assert.equal(inventory.updated, '2026-10-04', `updated is ${JSON.stringify(inventory.updated)}`);
  assert.deepEqual(inventory.items.map((i) => i.sku), ['L-1', 'C-7', 'B-2'], 'the three items should be loaded in order');
});

test('the inventory itself is checked', () => {
  assert.throws(() => parseInventory('null'), {message: 'the inventory must be an object'}, 'null should throw "the inventory must be an object"');
  assert.throws(() => parseInventory('{"updated": "2026-10-04", "items": 3}'), {message: 'items must be an array'}, 'items: 3 should throw "items must be an array"');
});

test('every item is checked too', () => {
  const bad = JSON.stringify({updated: '2026-10-04', items: [{...item('X-1', 1), price: 'cheap'}]});
  assert.throws(() => parseInventory(bad), {message: 'price must be a number'}, 'an item with price "cheap" should throw "price must be a number"');
});

test('lowStock lists the SKUs at or below the limit, sorted', () => {
  const got = lowStock(parseInventory(text), 3);
  assert.deepEqual(got, ['B-2', 'C-7'], `lowStock(inventory, 3) gave ${JSON.stringify(got)}`);
});

test('lowStock leaves the order of the items alone', () => {
  const inventory = parseInventory(text);
  lowStock(inventory, 3);
  assert.deepEqual(inventory.items.map((i) => i.sku), ['L-1', 'C-7', 'B-2'], 'after lowStock the items should still be in their order');
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Common mistakes

Trusting JSON.parse with an annotation

interface Inventory {
  items: { sku: string }[];
}

const inventory: Inventory = JSON.parse('{ "products": [] }');
console.log(inventory.items.length);

What tsc or Node.js prints

TypeError: Cannot read properties of undefined (reading 'length')

Why, and the fix

JSON.parse returns any, so tsc accepts the annotation without looking at the text, and the mistake shows up only when the program runs. Here the text has products, not items. Read the result as unknown and check it before you build an Inventory from it, as parseInventory does; then a wrong file stops with a clear message such as items must be an array.

Reading a property of unknown

function skuOf(value: unknown): string {
  return value.sku;
}

What tsc or Node.js prints

main.ts(2,10): error TS18046: 'value' is of type 'unknown'.

Why, and the fix

unknown allows nothing until you check what it is; that is its purpose. Check that it is an object first, with a predicate such as isRecord, then check the property itself: if (!isRecord(value)) fail("not an object"); const { sku } = value; if (typeof sku !== "string") fail("sku must be a string"); return sku;

Adding to a readonly list of items

interface Item {
  sku: string;
}
interface Inventory {
  readonly items: readonly Item[];
}

function addItem(inventory: Inventory, item: Item): void {
  inventory.items.push(item);
}

What tsc or Node.js prints

main.ts(9,19): error TS2339: Property 'push' does not exist on type 'readonly Item[]'.

Why, and the fix

The loaded inventory is readonly on purpose: summaries must not change it. To add an item, build a new inventory instead: function addItem(inventory: Inventory, item: Item): Inventory { return { ...inventory, items: [...inventory.items, item] }; }. The old one stays as it was, which also keeps earlier summaries valid.

TypeScript in the browser: the TypeScript 6.0.3 compiler, Apache-2.0, then your browser’s own engine. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

One model, every tool of the module

Each part of the data says what it is. sku is readonly, because it identifies the item; note?: string may be missing. The stock per warehouse has names nobody knows in advance: an index signature, readonly [warehouse: string]: number. Item extends Product, so a function that only needs a Product accepts an Item too. tags is a readonly string[], and location a readonly [string, number] tuple: aisle, then shelf. Inventory holds readonly items: readonly Item[]. Written down like this, tsc checks every function that reads the data, and it reports any that tries to change what should stay fixed.

From unknown to the model: check, then trust

JSON.parse returns any, so whatever the text holds is trusted without a check. Annotate the result as unknown instead: then tsc allows nothing until you check (error TS18046). isRecord(value): value is { [key: string]: unknown } checks for an object, and typeof null is "object", so it also tests value !== null. Destructure the fields, then check each with typeof and stop with fail(message): never. Write type predicates for the parts: isLocation for the tuple, isStock for the map. Array.isArray narrows unknown to any[], so a predicate must check the elements as well. tsc trusts a predicate completely: if it checks too little, the type lies.

Summaries read, never write

Once loaded, the data stays as it is. totalStock adds Object.values(item.stock), which is a number[] for an index signature of numbers. lowStock sorts a copy with toSorted, because items is readonly and has no sort; it returns a readonly string[], so callers cannot change the result either. Test the exports with node:test: assert.equal for a total, assert.deepEqual for a list, and assert.throws(() => parseInventory(text), { message: "items must be an array" }) for a rejected input. A test that runs a summary twice and compares the order of the items proves that nothing was changed.

Sources

Last reviewed October 4, 2026