Skip to content
aviral gupta

// B4.5 · ~38 min · Beginner

Functions together; build: a typed utility library

After this lesson you can write and test a small library of typed functions that uses everything from this module: callback types, flexible parameters, an overload set and a never-returning guard.

Lesson 5 of 5 in B4 More on functions

End of the module

You will be able to

  • Type callbacks with named function type expressions, and say which callbacks fit them
  • Combine optional, default and rest parameters with one overload set in a small library
  • Guard a library with a never-returning helper, and test its exports with node:test
  1. Warm-up · Activity 1 of 7

    Warm-up from JavaScript: countWhere counts the values for which test gives a truthy result. What does this print?

    function countWhere(values, test) {
      let count = 0;
      values.forEach((value, i) => {
        if (test(value, i)) count++;
      });
      return count;
    }
    
    console.log(countWhere([5, 0, 8], (v) => v), countWhere([5, 0, 8], (v, i) => i), countWhere([5, 0, 8], () => {}));
  2. Predict · Activity 2 of 7

    Predict before you read on. fail always throws, but has no return type. Which line does tsc reject?

    function fail(message: string) {
      throw new Error(message);
    }
    
    function required(value: number | undefined, name: string): number {
      if (value === undefined) fail(name + " is missing");
      return value;
    }
  3. Practice · Activity 3 of 7

    Fill in the return type of Predicate, so that tsc accepts both callbacks and the program prints 2 2.

    type Predicate = (value: number, index: number) => ____;
    
    function countWhere(values: number[], test: Predicate): number {
      let count = 0;
      values.forEach((value, i) => {
        if (test(value, i)) count++;
      });
      return count;
    }
    
    console.log(countWhere([72, 100, 64, 0], (s) => s >= 65), countWhere([5, 0, 8], (v, i) => i > 0));
    type Predicate = (value: number, index: number) => ;
  4. Practice · Activity 4 of 7

    join should work as join(), join("-") and join("-", "a", "b"). Which first line makes all three calls compile?

    function join(/* ? */) {
      return words.join(separator);
    }
    
    console.log(join(), join("-"), join("-", "a", "b"));
  5. Practice · Activity 5 of 7

    last has two overloads. Which type does b have?

    function last(text: string): string;
    function last(values: number[]): number;
    function last(input: string | number[]): string | number {
      return input[input.length - 1];
    }
    
    const b = last([4, 2]);
    // what is b here?
  6. Brain teaser · Activity 6 of 7

    Brain teaser. last is called with an empty string. What happens when you run npx tsc, then node main.ts?

    function last(text: string): string;
    function last(values: number[]): number;
    function last(input: string | number[]): string | number {
      return input[input.length - 1];
    }
    
    const letter = last("");
    console.log(typeof letter, letter === undefined);
  7. Apply · Activity 7 of 7

    Mini-task. Write a small library for words. A type WordTest for a callback that takes a word and returns a boolean, and firstWhere(list, test), which returns the first word that passes or calls fail(message): never. A type WordVisitor whose result is ignored, and eachWord(list, visit). shorten(text, max = 5, ...marks), which cuts text to max characters and appends the marks. And one overload set whose return type depends on the argument, such as double(value: number): number and double(value: string): string. Call each function once. 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

A small library of number helpers

The whole module in one file. Predicate and Visitor are the callback types; fail is the never-returning guard that required and last use; clamp has defaults and sum a rest parameter; last has two overloads. The calls below use each export once. Run npx tsc, then node main.ts. Then remove : never from fail and run npx tsc again.

main.ts

// A small typed utility library, then a few calls that use it.
export type Predicate = (value: number, index: number) => boolean;
export type Visitor = (value: number, index: number) => void;

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

export function required(value: number | undefined, name: string): number {
  if (value === undefined) fail(name + " is missing");
  return value; // a number here: fail never returns
}

export function clamp(value: number, min = 0, max = 100): number {
  return Math.min(Math.max(value, min), max);
}

export function sum(...values: number[]): number {
  let total = 0;
  for (const value of values) total += value;
  return total;
}

export function countWhere(values: number[], test: Predicate): number {
  let count = 0;
  values.forEach((value, i) => {
    if (test(value, i)) count++;
  });
  return count;
}

export function each(values: number[], visit: Visitor): void {
  values.forEach((value, i) => visit(value, i));
}

export function last(text: string): string;
export function last(values: number[]): number;
export function last(input: string | number[]): string | number {
  if (input.length === 0) fail("last() needs at least one element");
  return input[input.length - 1];
}

const scores = [72, 105, 64, -3];
const fixed = scores.map((s) => clamp(s));
console.log("clamped:", fixed.join(" "), "sum:", sum(...fixed));
console.log("passed:", countWhere(fixed, (s) => s >= 65));
const lines: string[] = [];
each(fixed, (s, i) => lines.push("#" + (i + 1) + "=" + s));
console.log(lines.join(", "));
console.log("last:", last("Ada"), last(fixed) + 1);
try {
  required(undefined, "width");
} catch (error) {
  console.log(error instanceof Error ? error.message : error);
}

Run it with

npx tsc
node main.ts

Output

clamped: 72 100 64 0 sum: 236
passed: 2
#1=72, #2=100, #3=64, #4=0
last: a 1
width is missing
  • clamp(s) uses both defaults, so 105 becomes 100 and -3 becomes 0. sum(...fixed) spreads the array into the rest parameter.
  • The callback for each returns push's number, and the Visitor type, which returns void, accepts it.
  • last(fixed) is a number, by the second overload, so + 1 is arithmetic: 0 + 1.
  • Without : never, fail is inferred as void, and return value in required is error TS2322.
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

Defaults, an optional unit and a rest parameter

The three calls at the bottom do not fit the signatures, so tsc reports error TS2554 for clamp(104) and label(5). Change the signatures, not the calls, except total: clamp gets the defaults min = 0 and max = 100; unit in label becomes optional, and label(5) gives "5"; sum gets a rest parameter, and total becomes sum(3, 4.5, 2).

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 default is written after the parameter: min = 0. Its type, number, is inferred from the default.

  2. Hint 2

    unit?: string makes unit optional. Inside label it is string | undefined, so check unit === undefined and return String(value) then.

  3. Hint 3

    A rest parameter: ...values: number[]. The body stays the same, as values is still an array.

Show a solution

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

export function clamp(value: number, min = 0, max = 100): number {
  return Math.min(Math.max(value, min), max);
}

export function sum(...values: number[]): number {
  let total = 0;
  for (const value of values) total += value;
  return total;
}

export function label(value: number, unit?: string): string {
  return unit === undefined ? String(value) : value + " " + unit;
}

export const percent = clamp(104);
export const total = sum(3, 4.5, 2);
export const size = label(5);
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 function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

export function sum(values: number[]): number {
  let total = 0;
  for (const value of values) total += value;
  return total;
}

export function label(value: number, unit: string): string {
  return value + " " + unit;
}

export const percent = clamp(104);
export const total = sum([3, 4.5, 2]);
export const size = label(5);

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {clamp, sum, label, percent, total, size} from './main.ts';

test('clamp uses 0 and 100 as defaults', () => {
  assert.equal(clamp(150), 100, `clamp(150) returned ${clamp(150)}`);
  assert.equal(clamp(-5), 0, `clamp(-5) returned ${clamp(-5)}`);
  assert.equal(percent, 100, `percent is ${percent}`);
});

test('clamp takes its own limits, and undefined keeps a default', () => {
  assert.equal(clamp(15, 20), 20, `clamp(15, 20) returned ${clamp(15, 20)}`);
  assert.equal(clamp(15, undefined, 10), 10, `clamp(15, undefined, 10) returned ${clamp(15, undefined, 10)}`);
});

test('sum takes any number of numbers', () => {
  assert.equal(sum(), 0, `sum() returned ${sum()}`);
  assert.equal(sum(3, 4.5, 2), 9.5, `sum(3, 4.5, 2) returned ${sum(3, 4.5, 2)}`);
  assert.equal(total, 9.5, `total is ${total}`);
});

test('label leaves out a missing unit', () => {
  assert.equal(label(5), '5', `label(5) returned ${JSON.stringify(label(5))}`);
  assert.equal(label(5, 'kg'), '5 kg', `label(5, 'kg') returned ${JSON.stringify(label(5, 'kg'))}`);
  assert.equal(size, '5', `size is ${JSON.stringify(size)}`);
});

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

Callback types instead of Function

countWhere and each take their callbacks as Function, so the callers' parameters score and i have no type: tsc reports error TS7006: Parameter 'score' implicitly has an 'any' type. Declare and export two function types: Predicate, for a callback that takes a value and its index and returns a boolean, and Visitor, for one that takes the same and whose result is ignored. Use them in place of Function.

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 function type expression is written like an arrow function: (value: number, index: number) => boolean.

  2. Hint 2

    The visitor's callback returns push's number. Which return type accepts a callback whatever it returns?

  3. Hint 3

    export type Visitor = (value: number, index: number) => void; then test: Predicate and visit: Visitor.

Show a solution

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

export type Predicate = (value: number, index: number) => boolean;
export type Visitor = (value: number, index: number) => void;

export function countWhere(values: number[], test: Predicate): number {
  let count = 0;
  values.forEach((value, i) => {
    if (test(value, i)) count++;
  });
  return count;
}

export function each(values: number[], visit: Visitor): void {
  values.forEach((value, i) => visit(value, i));
}

export const passed = countWhere([72, 100, 64, 0], (score) => score >= 65);
export const labels: string[] = [];
each([72, 100], (score, i) => labels.push("#" + (i + 1) + "=" + score));
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 function countWhere(values: number[], test: Function): number {
  let count = 0;
  values.forEach((value, i) => {
    if (test(value, i)) count++;
  });
  return count;
}

export function each(values: number[], visit: Function): void {
  values.forEach((value, i) => visit(value, i));
}

export const passed = countWhere([72, 100, 64, 0], (score) => score >= 65);
export const labels: string[] = [];
each([72, 100], (score, i) => labels.push("#" + (i + 1) + "=" + score));

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {countWhere, each, passed, labels} from './main.ts';

test('countWhere counts the values that pass', () => {
  const n = countWhere([72, 100, 64, 0], (s) => s >= 65);
  assert.equal(n, 2, `countWhere([72, 100, 64, 0], s >= 65) returned ${n}`);
  assert.equal(passed, 2, `passed is ${passed}`);
});

test('countWhere passes the index as well', () => {
  const n = countWhere([5, 0, 8], (v, i) => i > 0);
  assert.equal(n, 2, `countWhere with i > 0 returned ${n}`);
});

test('each calls the visitor with each value and its index', () => {
  const seen = [];
  each([7, 9], (v, i) => seen.push(v + '@' + i));
  assert.deepEqual(seen, ['7@0', '9@1'], `each visited ${JSON.stringify(seen)}`);
  assert.deepEqual(labels, ['#1=72', '#2=100'], `labels is ${JSON.stringify(labels)}`);
});

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

Build: an overload set and a never guard

Finish the library. Step 1: give fail the return type never, so that required compiles. Step 2: give last two overloads above the implementation, last(text: string): string and last(values: number[]): number, so that last("Ada").toUpperCase() compiles. Step 3: last must not return undefined for an empty string or array: call fail with the message "last() needs at least one element" first. The tests check each step.

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

    Start with tsc's errors: one is in required, one is the toUpperCase call. Each step removes one.

  2. Hint 2

    Overloads are headers that end in ; with no body, written directly above the implementation, which keeps its union signature.

  3. Hint 3

    In last, before the return: if (input.length === 0) fail("last() needs at least one element");

Show a solution

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

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

export function required(value: number | undefined, name: string): number {
  if (value === undefined) fail(name + " is missing");
  return value;
}

export function last(text: string): string;
export function last(values: number[]): number;
export function last(input: string | number[]): string | number {
  if (input.length === 0) fail("last() needs at least one element");
  return input[input.length - 1];
}

export const initial: string = last("Ada").toUpperCase();
export const width = required(80, "width");
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 function fail(message: string) {
  throw new Error(message);
}

export function required(value: number | undefined, name: string): number {
  if (value === undefined) fail(name + " is missing");
  return value;
}

export function last(input: string | number[]): string | number {
  return input[input.length - 1];
}

export const initial: string = last("Ada").toUpperCase();
export const width = required(80, "width");

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {fail, required, last, initial, width} from './main.ts';

test('fail throws an Error with the message', () => {
  assert.throws(() => fail('stop'), {message: 'stop'}, 'fail("stop") should throw an Error with the message "stop"');
});

test('required returns a value that is there, and throws for a missing one', () => {
  assert.equal(width, 80, `width is ${width}`);
  assert.throws(() => required(undefined, 'height'), {message: 'height is missing'}, 'required(undefined, "height") should throw "height is missing"');
});

test('last returns the last letter or number', () => {
  assert.equal(last('Ada'), 'a', `last('Ada') returned ${JSON.stringify(last('Ada'))}`);
  assert.equal(last([4, 2]), 2, `last([4, 2]) returned ${last([4, 2])}`);
  assert.equal(initial, 'A', `initial is ${JSON.stringify(initial)}`);
});

test('last throws for an empty string or array', () => {
  assert.throws(() => last([]), {message: 'last() needs at least one element'}, 'last([]) should throw "last() needs at least one element"');
  assert.throws(() => last(''), {message: 'last() needs at least one element'}, 'last("") should throw "last() needs at least one element"');
});

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

An array passed to a rest parameter

function sum(...values: number[]): number {
  let total = 0;
  for (const value of values) total += value;
  return total;
}

const prices = [3, 4.5, 2];
console.log(sum(prices));

What tsc or Node.js prints

main.ts(8,17): error TS2345: Argument of type 'number[]' is not assignable to parameter of type 'number'.

Why, and the fix

A rest parameter collects separate arguments, so each argument must be a number, and prices is an array. Spread it: sum(...prices). If callers usually have an array, take the array instead: function sum(values: number[]).

An assertion helper without : never

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

function size(value: string | null): number {
  if (value === null) fail("value is null");
  return value.length;
}

What tsc or Node.js prints

main.ts(7,10): error TS18047: 'value' is possibly 'null'.

Why, and the fix

fail always throws, but without a return type tsc infers void for it, so after the if, value may still be null. Declare function fail(message: string): never. Then tsc knows that the code after fail(...) is reached only when value is not null.

A predicate that returns a value, not a boolean

type Predicate = (value: number, index: number) => boolean;

function countWhere(values: number[], test: Predicate): number {
  let count = 0;
  values.forEach((value, i) => {
    if (test(value, i)) count++;
  });
  return count;
}

console.log(countWhere([72, 0], (s) => String(s)));

What tsc or Node.js prints

main.ts(11,40): error TS2322: Type 'string' is not assignable to type 'boolean'.

Why, and the fix

In JavaScript any truthy result would count, but Predicate promises a boolean. Return a comparison, such as (s) => s !== 0 or (s) => String(s) !== "". If the library should accept any result, that is a decision for its type, not for one caller.

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

Name the callback types first

A library is used by code you do not see, so its signatures do the explaining. Name each callback type with a function type expression: type Predicate = (value: number, index: number) => boolean. Then countWhere(values: number[], test: Predicate) checks every callback a caller writes. (s) => s >= 65 fits, since a callback may take fewer parameters, while (s) => String(s) is error TS2322: Type 'string' is not assignable to type 'boolean'. A callback whose result you ignore returns void, so (s, i) => lines.push("#" + i) fits Visitor = (value: number, index: number) => void. The type Function checks nothing: a caller's callback parameters become implicit any, error TS7006.

Flexible parameters, and one overload set

Defaults cover the common case: clamp(value: number, min = 0, max = 100) takes one, two or three arguments, and undefined picks the default. unit?: string is optional, so check it for undefined before use. A rest parameter, ...values: number[], takes any number of numbers; pass an array as sum(...prices). Write overloads only where the return type depends on the argument: last(text: string): string and last(values: number[]): number. Then last("Ada") is a string, so last("Ada").toUpperCase() compiles; with one signature returning string | number it is error TS2339. These functions take concrete types; one function for any element type needs generics, which come in the Intermediate level.

A never helper guards the edges, and tests check the exports

Types cannot see every value: last("") type-checks as a string but gives undefined. Check at run time with function fail(message: string): never { throw new Error(message); }. The return type matters: after if (value === undefined) fail(...), tsc knows value is a number. Without : never, a function declaration that only throws is inferred as void, and return value is error TS2322. Test the exports with node:test, importing them from main.ts. assert.equal(sum(1, 2), 3, "sum(1, 2)") checks a result; assert.throws(() => last([]), { message: "last() needs at least one element" }) checks that a call throws. Pass assert.throws a function, not the call itself.

Sources

Last reviewed October 4, 2026