Warm-up · Activity 1 of 7
Warm-up: a parameter is declared as id: number | string. What does that type say?
function printId(id: number | string) {
console.log("Your ID is: " + id);
}// B3.1 · ~30 min · Beginner
After this lesson you can look at a function that takes a union type, say which line the compiler will reject and why, and make a switch that fails to compile when a new case is added.
Lesson 1 of 5 in B3 Narrowing
You will be able to
Warm-up · Activity 1 of 7
function printId(id: number | string) {
console.log("Your ID is: " + id);
}Predict · Activity 2 of 7
function size(value: string[] | null) {
if (typeof value === "object") {
return value.length;
}
return 0;
}Practice · Activity 3 of 7
type Fish = { swim: () => void };
type Bird = { fly: () => void };
interface Circle { kind: "circle"; radius: number }
interface Square { kind: "square"; sideLength: number }
type Shape = Circle | Square;Practice · Activity 4 of 7
function handle(a: any, b: unknown) {
a.trim(); // line 2
const n: number = a; // line 3
b.trim(); // line 4
if (typeof b === "string") b.trim(); // line 5
}Practice · Activity 5 of 7
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: ____ = shape;
return _exhaustiveCheck;
}
}Brain teaser · Activity 6 of 7
function example(x: string | number, y: string | boolean) {
if (x === y) {
// what is the type of x here?
}
}Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
show takes string | number | null and removes one member per check: === null first, then typeof. area switches on the kind property of a discriminated union, with a never check in the default branch. toCount takes unknown, as for JSON.parse results, and uses only what it has checked. Run npx tsc: no output means no errors. Then run node main.ts. Try adding | { kind: "triangle"; base: number } to Shape and run tsc again: the never line is reported.
main.ts
// Three kinds of narrowing on one page. tsc follows each check.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // here shape is the circle member
case "rect":
return shape.width * shape.height; // here shape is the rect member
default: {
const unhandled: never = shape; // a new kind without a case fails here
return unhandled;
}
}
}
function show(value: string | number | null): string {
if (value === null) return "nothing"; // === null removes null
if (typeof value === "number") return value.toFixed(1); // value is number
return value.toUpperCase(); // only string is left
}
function toCount(input: unknown): number {
if (typeof input === "number") return input;
if (typeof input === "string" && input.trim() !== "") return Number(input);
return 0; // anything else: null, objects, empty text
}
console.log(show(null), show(2.5), show("ok"));
console.log(area({ kind: "circle", radius: 1 }).toFixed(2));
console.log(area({ kind: "rect", width: 2, height: 3 }));
console.log(toCount(JSON.parse("4")), toCount(JSON.parse('"7"')), toCount(JSON.parse("null")));
Run it with
npx tsc
node main.tsOutput
nothing 2.5 OK
3.14
6
4 7 0Tab 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.
Exercise 1 of 2
formatAmount takes number | string | null. It already handles text, but tsc reports error TS18047: 'amount' is possibly 'null', and formatAmount(null) crashes. Make it return "no amount" for null, and keep the other results. 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.
The diagnostic points at the last return: after the typeof branch, amount is still number | null there.
An equality check removes null: if (amount === null) { … } at the top of the function.
Do not use typeof amount === "object" for this: it works here only by accident, because typeof null is "object".
One way to solve it. Yours can look different and still pass the checks.
export function formatAmount(amount: number | string | null): string {
if (amount === null) {
return "no amount";
}
if (typeof amount === "string") {
return Number(amount).toFixed(2) + " EUR";
}
return amount.toFixed(2) + " EUR";
}
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 function formatAmount(amount: number | string | null): string {
if (typeof amount === "string") {
return Number(amount).toFixed(2) + " EUR";
}
return amount.toFixed(2) + " EUR";
}
main.test.ts
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {formatAmount} from './main.ts';
test('formatAmount(4.5) is "4.50 EUR"', () => {
assert.equal(formatAmount(4.5), '4.50 EUR', 'formatAmount(4.5) should be "4.50 EUR"');
});
test('formatAmount("12") is "12.00 EUR"', () => {
assert.equal(formatAmount('12'), '12.00 EUR', 'formatAmount("12") should be "12.00 EUR"');
});
test('formatAmount(null) is "no amount"', () => {
assert.equal(formatAmount(null), 'no amount', 'formatAmount(null) should be "no amount"');
});
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.tsRun the checks (needs learnrun.js in the same folder):
npx tsc
node --testDownload learnrun.jsExercise 2 of 2
Payment gained a third member, { method: "invoice"; days: number }, and tsc now reports the never line in describePayment. Add the missing case so that an invoice for 30 days gives "invoice, due in 30 days". Keep the never check in the default branch.
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.
The diagnostic says which member reaches the default branch: { method: "invoice"; days: number; } is not assignable to never.
Add case "invoice": before default. Inside it, p is the invoice member, so p.days is a number.
return "invoice, due in " + p.days + " days";
One way to solve it. Yours can look different and still pass the checks.
export type Payment =
| { method: "card"; last4: string }
| { method: "paypal"; email: string }
| { method: "invoice"; days: number };
export function describePayment(p: Payment): string {
switch (p.method) {
case "card":
return "card ending " + p.last4;
case "paypal":
return "PayPal " + p.email;
case "invoice":
return "invoice, due in " + p.days + " days";
default: {
const unhandled: never = p;
return unhandled;
}
}
}
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 type Payment =
| { method: "card"; last4: string }
| { method: "paypal"; email: string }
| { method: "invoice"; days: number };
export function describePayment(p: Payment): string {
switch (p.method) {
case "card":
return "card ending " + p.last4;
case "paypal":
return "PayPal " + p.email;
default: {
const unhandled: never = p;
return unhandled;
}
}
}
main.test.ts
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {describePayment} from './main.ts';
test('a card payment names the last four digits', () => {
assert.equal(describePayment({method: 'card', last4: '4242'}), 'card ending 4242', 'the card payment should give "card ending 4242"');
});
test('a PayPal payment names the email address', () => {
assert.equal(describePayment({method: 'paypal', email: 'ada@example.com'}), 'PayPal ada@example.com', 'the PayPal payment should give "PayPal ada@example.com"');
});
test('an invoice names the days until it is due', () => {
assert.equal(describePayment({method: 'invoice', days: 30}), 'invoice, due in 30 days', 'the invoice should give "invoice, due in 30 days"');
});
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.tsRun the checks (needs learnrun.js in the same folder):
npx tsc
node --testDownload learnrun.jsfunction count(items: string[] | null): number {
if (typeof items === "object") {
return items.length;
}
return 0;
}
console.log(count(["a", "b"]));
What tsc or Node.js prints
main.ts(3,12): error TS18047: 'items' is possibly 'null'.Why, and the fix
typeof null is "object" in JavaScript, so the check keeps both string[] and null, and tsc says so. Compare with null instead: if (items !== null), or items != null to remove undefined as well. A truthiness check, if (items), also works here, but it would also skip an empty string or 0 in other unions.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
return Math.PI * shape.radius ** 2;
}
console.log(area({ kind: "circle", radius: 1 }));
What tsc or Node.js prints
main.ts(6,26): error TS2339: Property 'radius' does not exist on type 'Shape'.Why, and the fix
shape could be the square, which has no radius, and a union allows only what every member allows. Check the discriminant first: if (shape.kind === "circle") return Math.PI * shape.radius ** 2; then handle "square". A switch over shape.kind with a never check in default makes tsc remind you of every kind you have not handled.
type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Animal = Fish | Bird;
function move(animal: Animal) {
if (animal.swim) {
animal.swim();
}
}
move({ swim: () => console.log("swimming") });
What tsc or Node.js prints
main.ts(6,14): error TS2339: Property 'swim' does not exist on type 'Animal'.Why, and the fix
In JavaScript, if (animal.swim) is a common way to test for a method, but TypeScript rejects reading swim before it knows animal is a Fish: Bird has no swim. The in operator is the check TypeScript understands: if ("swim" in animal) narrows animal to Fish, and animal.swim() compiles. In the else branch, animal is a Bird.
TypeScript in the browser: the TypeScript 6.0.3 compiler, Apache-2.0, then your browser’s own engine. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.