Emmanuel Akinfulubi

TypeScript: Modules and namespaces

Named and default exports, circular imports, namespaces, nesting and declaration merging.

9 exercises

1 Export add and subtract, then import them

easy

Write a module exporting two functions, one adding two numbers and one subtracting them. Import both into another file and use them.

Stuck? Three hints, smallest first
  1. 1. A named export is just export in front of the declaration.
  2. 2. In modern TypeScript the import path keeps the .js extension even though the file is .ts.
  3. 3. Anything without export is private to its own file. That is the default, and it is a good one.

The real answer, across 2 files. Type-checked by the compiler at build time; not runnable in a browser, because a panel cannot contain import or export.

maths.ts

// Two named exports. Everything else in this file stays private.
export function add(a: number, b: number): number {
  return a + b;
}

export function subtract(a: number, b: number): number {
  return a - b;
}

// Not exported, so no other file can reach it.
function unused(): void {}

main.ts

import { add, subtract } from './maths.js';

console.log(add(2, 3));        // 5
console.log(subtract(10, 4));  // 6

// This would be an error, because `unused` was never exported:
// import { unused } from './maths.js';

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The same idea without module syntax, so it can run here. An object literal
// is the closest runnable stand-in for a module: named things on the outside,
// and anything not attached to it stays private.
const maths = {
  add(a: number, b: number): number { return a + b; },
  subtract(a: number, b: number): number { return a - b; },
};

// Destructuring an object is exactly what `import { add } from ...` feels like.
const { add, subtract } = maths;

console.log(add(2, 3));
console.log(subtract(10, 4));

test('add', add(2, 3), 5);
test('subtract', subtract(10, 4), 6);
test('add is commutative', add(2, 3), add(3, 2));
test('subtract is not', subtract(10, 4) === subtract(4, 10), false);
test('both came off the one object', typeof maths.add, 'function');

Output


      

Two blocks, and the reason is worth knowing. The file pair above is the real answer and the compiler checked it. The runnable block below cannot use import, because this page runs each panel inside a function and module syntax is only legal at the top level of a file. Notice that const { add, subtract } = maths reads almost exactly like the import — that is not a coincidence, it is what a named import does.

Answers TS Modules 1 in the original set.

2 Export a class, then build instances elsewhere

easy

Write a module exporting a student class with a name and an age. Import it into another file, create a couple of students and print their details.

Stuck? Three hints, smallest first
  1. 1. A class is exported the same way a function is: export class Student { ... }.
  2. 2. You can export the type as well as the class; a class name is both a value and a type.
  3. 3. Give it a toString() so printing a student reads well.

The real answer, across 2 files. Type-checked by the compiler at build time; not runnable in a browser, because a panel cannot contain import or export.

student.ts

export class Student {
  constructor(
    public readonly name: string,
    public readonly age: number,
  ) {}

  describe(): string {
    return `${this.name}, ${this.age}`;
  }

  toString(): string {
    return this.describe();
  }
}

// A type alias can travel with it.
export type StudentList = readonly Student[];

roll.ts

import { Student, type StudentList } from './student.js';

const roll: StudentList = [
  new Student('Amaka Obi', 17),
  new Student('Chidi Eze', 18),
];

for (const s of roll) console.log(s.describe());

// A class name is a value AND a type, so this is legal:
const first: Student = roll[0]!;
console.log(String(first));

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The class itself needs no module syntax, so this part is the real thing.
class Student {
  constructor(
    public readonly name: string,
    public readonly age: number,
  ) {}

  describe(): string {
    return `${this.name}, ${this.age}`;
  }

  toString(): string {
    return this.describe();
  }
}

type StudentList = readonly Student[];

const roll: StudentList = [
  new Student('Amaka Obi', 17),
  new Student('Chidi Eze', 18),
];

for (const s of roll) console.log(s.describe());
console.log('printed via toString:', `${roll[0]}`);

test('two students on the roll', roll.length, 2);
test('describe', roll[0]?.describe(), 'Amaka Obi, 17');
test('toString is used in a template', `${roll[1]}`, 'Chidi Eze, 18');
test('readonly array rejects push at compile time', Array.isArray(roll), true);
test('the class name works as a type too', roll[0] instanceof Student, true);

Output


      

Note import { Student, type StudentList } in the reference. The type keyword inside the braces says "this one is only a type, erase it". Useful because a class is both a value and a type, and being explicit stops bundlers keeping code they do not need.

Answers TS Modules 2 in the original set.

3 Several named exports, imported selectively

easy

Write a module exporting a few functions and values by name. In another file import only some of them and use those.

Stuck? Three hints, smallest first
  1. 1. You can also write one export { a, b, c } statement at the bottom instead of marking each declaration.
  2. 2. Importing only what you need is what lets a bundler drop the rest.
  3. 3. import * as name pulls everything in under one object, which is handy but blocks that trimming.

The real answer, across 2 files. Type-checked by the compiler at build time; not runnable in a browser, because a panel cannot contain import or export.

fleet.ts

export const PARK = 'Jibowu';
export const MAX_SEATS = 59;

export function isPlate(text: string): boolean {
  return /^[A-Z]{3}-\d{3}-[A-Z]{2}$/.test(text);
}

export function fare(seats: number, luxury = false): number {
  return seats * (luxury ? 18000 : 12000) + 200;
}

function internalOnly(): string {
  return 'not exported';
}

// The alternative spelling: one list at the bottom.
export { internalOnly as debugOnly };

desk.ts

// Only what this file actually uses.
import { fare, isPlate } from './fleet.js';

console.log(isPlate('LAG-441-XA'));   // true
console.log(fare(2));                 // 24200
console.log(fare(2, true));           // 36200

// Everything at once, under one name, when you really want it all:
import * as fleet from './fleet.js';
console.log(fleet.PARK, fleet.MAX_SEATS);

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The same members, gathered on one object so this can run.
const PARK = 'Jibowu';
const MAX_SEATS = 59;

function isPlate(text: string): boolean {
  return /^[A-Z]{3}-\d{3}-[A-Z]{2}$/.test(text);
}

function fare(seats: number, luxury = false): number {
  return seats * (luxury ? 18000 : 12000) + 200;
}

// This object plays the part of the module's public surface.
const fleet = { PARK, MAX_SEATS, isPlate, fare };

// And this plays the part of a selective import.
const { fare: fareOnly, isPlate: checkPlate } = fleet;

console.log(checkPlate('LAG-441-XA'), checkPlate('nope'));
console.log(fareOnly(2), fareOnly(2, true));
console.log(fleet.PARK, fleet.MAX_SEATS);

test('a valid plate', checkPlate('LAG-441-XA'), true);
test('an invalid one', checkPlate('nope'), false);
test('economy fare for two', fareOnly(2), 24200);
test('luxury fare for two', fareOnly(2, true), 36200);
test('the constants came through', fleet.PARK, 'Jibowu');
test('the surface has exactly four members', Object.keys(fleet).length, 4);

Output


      

The export { internalOnly as debugOnly } line in the reference is worth spotting: you can rename on the way out, and rename again on the way in with import { debugOnly as d }. Handy when two modules would otherwise collide on a name.

Answers TS Modules 3 in the original set.

4 A default export, imported under any name

easy

Write a module whose main thing is a default export. Import it elsewhere and use it. Note what a default export gives you and what it costs.

Stuck? Three hints, smallest first
  1. 1. A file may have at most one export default.
  2. 2. The importer chooses the name, which is flexible and also means two files can call the same thing differently.
  3. 3. A default and named exports can live in the same file.

The real answer, across 2 files. Type-checked by the compiler at build time; not runnable in a browser, because a panel cannot contain import or export.

logger.ts

// One default export, plus a named one alongside it.
export type Level = 'info' | 'warn';

export default class Logger {
  private lines: string[] = [];

  log(level: Level, message: string): this {
    this.lines.push(`[${level}] ${message}`);
    return this;
  }

  get output(): readonly string[] {
    return this.lines;
  }
}

export const DEFAULT_LEVEL: Level = 'info';

app.ts

// The name is the importer's choice: no braces, so this is the default.
import Log, { DEFAULT_LEVEL, type Level } from './logger.js';

const log = new Log();
log.log(DEFAULT_LEVEL, 'started').log('warn', 'low on seats');

for (const line of log.output) console.log(line);

// Nothing stops a second file calling it something else entirely, which is
// the main argument against default exports on a big team.
const level: Level = 'warn';
console.log('level:', level);

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

type Level = 'info' | 'warn';

class Logger {
  private lines: string[] = [];

  log(level: Level, message: string): this {
    this.lines.push(`[${level}] ${message}`);
    return this;
  }

  get output(): readonly string[] {
    return this.lines;
  }
}

const DEFAULT_LEVEL: Level = 'info';

// A default export is really just "the one thing this file is about".
// Here that is the class itself, so we use it directly.
const log = new Logger();
log.log(DEFAULT_LEVEL, 'started').log('warn', 'low on seats');

for (const line of log.output) console.log(line);

test('two lines were logged', log.output.length, 2);
test('the default level was used', log.output[0], '[info] started');
test('the second line', log.output[1], '[warn] low on seats');
test('chaining worked', new Logger().log('info', 'a').log('info', 'b').output.length, 2);

Output


      

Worth having an opinion here. A default export lets each importer pick its own name, which sounds convenient and means the same class can appear as Log, Logger and L across three files. Named exports keep one name everywhere and are easier to search for. Most large codebases now prefer named exports for exactly that reason.

Answers TS Modules 4 in the original set.

5 Two modules that import each other

hard

Write two modules that each import from the other, creating a circular dependency. Show what works, what breaks, and how to avoid the problem.

Stuck? Three hints, smallest first
  1. 1. Circular imports of types are completely fine, because types are erased.
  2. 2. Circular imports of values work only if nothing is used while the modules are still loading.
  3. 3. The reliable fix is a third module holding whatever both need.

The real answer, across 2 files. Type-checked by the compiler at build time; not runnable in a browser, because a panel cannot contain import or export.

bus.ts

import type { Trip } from './trip.js';

export class Bus {
  constructor(public plate: string) {}

  // A type-only reference to Trip. Erased, so no runtime cycle at all.
  describeTrip(trip: Trip): string {
    return `${this.plate} runs ${trip.route}`;
  }
}

trip.ts

import { Bus } from './bus.js';

export class Trip {
  constructor(public route: string, public bus: Bus) {}

  describe(): string {
    // Called later, long after both modules have finished loading, so the
    // value-level cycle is harmless here.
    return this.bus.describeTrip(this);
  }
}

// This, by contrast, would run DURING loading and is where cycles bite:
// export const EAGER = new Bus('LAG-441-XA').plate;

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The runnable version, which also shows the fix: no cycle at all, because
// the shared shape lives on its own and neither side reaches back.

// What both sides agree on, and what a third module would hold.
interface HasRoute { route: string }

class Bus {
  constructor(public plate: string) {}

  describeTrip(trip: HasRoute): string {
    return `${this.plate} runs ${trip.route}`;
  }
}

class Trip implements HasRoute {
  constructor(public route: string, public bus: Bus) {}

  describe(): string {
    return this.bus.describeTrip(this);
  }
}

const bus = new Bus('LAG-441-XA');
const trip = new Trip('Lagos - Abuja', bus);

console.log(trip.describe());
console.log(bus.describeTrip({ route: 'Lagos - Kano' }));

// Why eager use during loading is the real danger, in miniature:
// if this ran while the other module was still initialising, `later` would
// still be undefined and the call would throw.
let later: ((s: string) => string) | undefined;
const tooEarly = typeof later === 'function' ? later('x') : 'not ready yet';
later = (s) => `ready: ${s}`;

console.log('during loading:', tooEarly);
console.log('after loading :', later('x'));

test('the trip describes itself through the bus', trip.describe(), 'LAG-441-XA runs Lagos - Abuja');
test('the bus accepts anything with a route', bus.describeTrip({ route: 'Lagos - Kano' }), 'LAG-441-XA runs Lagos - Kano');
test('using a value too early gives nothing', tooEarly, 'not ready yet');
test('the same call works once loading finished', later('x'), 'ready: x');
test('Trip satisfies the shared shape', trip.route, 'Lagos - Abuja');

Output


      

The distinction that resolves most circular-import confusion: import type can never cause a cycle, because it is erased before the JavaScript exists. Cycles only bite when a value is used while the modules are still loading, as the commented EAGER line would be. The runnable block shows the durable fix — put the shared shape somewhere neither side has to import the other for.

Answers TS Modules 5 in the original set.

6 A MathUtility namespace

easy

Write a namespace holding functions for add, subtract, multiply and divide. Call them through the namespace name.

Stuck? Three hints, smallest first
  1. 1. Inside a namespace, only what you mark export is reachable from outside it.
  2. 2. A namespace compiles to a plain object, so it runs here with no adaptation.
  3. 3. Namespaces are largely superseded by modules. Know them for reading older code.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

namespace MathUtility {
  // Exported: reachable as MathUtility.add
  export function add(a: number, b: number): number { return a + b; }
  export function subtract(a: number, b: number): number { return a - b; }
  export function multiply(a: number, b: number): number { return a * b; }

  export function divide(a: number, b: number): number {
    if (b === 0) throw new RangeError('cannot divide by zero');
    return a / b;
  }

  // Not exported, so MathUtility.round is not available outside.
  function round(n: number): number { return Math.round(n); }

  export function average(values: number[]): number {
    if (values.length === 0) return 0;
    return round(values.reduce((n, v) => n + v, 0) / values.length);
  }
}

console.log(MathUtility.add(2, 3));
console.log(MathUtility.subtract(10, 4));
console.log(MathUtility.multiply(6, 7));
console.log(MathUtility.divide(20, 4));
console.log(MathUtility.average([1, 2, 3, 4]));

let refused = '';
try { MathUtility.divide(1, 0); } catch (err) { refused = (err as RangeError).message; }

// MathUtility.round(1.5);   // error: Property 'round' does not exist

test('add', MathUtility.add(2, 3), 5);
test('subtract', MathUtility.subtract(10, 4), 6);
test('multiply', MathUtility.multiply(6, 7), 42);
test('divide', MathUtility.divide(20, 4), 5);
test('divide by zero refused', refused, 'cannot divide by zero');
test('average uses the private helper', MathUtility.average([1, 2, 3, 4]), 3);
test('an empty list averages to zero', MathUtility.average([]), 0);

Output


      

round is deliberately not exported, which shows that a namespace has a real inside and outside just as a module does. Do learn namespaces, because plenty of older code and many .d.ts files use them — but for new code, a module is almost always the right choice.

Answers TS Modules 6 in the original set.

7 A Geometry namespace with two nested inside it

fair

Write a namespace called Geometry containing two nested namespaces: one holding shape classes and one holding utility functions. Show how to reach into both.

Stuck? Three hints, smallest first
  1. 1. Nest by writing export namespace Shapes { ... } inside the outer one.
  2. 2. Every level you want reachable from outside needs its own export.
  3. 3. An alias shortens a long path: import S = Geometry.Shapes; — legal inside a namespace context.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

namespace Geometry {
  export namespace Shapes {
    export class Circle {
      constructor(public radius: number) {}
    }

    export class Rectangle {
      constructor(public width: number, public height: number) {}
    }
  }

  export namespace Utilities {
    export function calculateArea(shape: Shapes.Circle | Shapes.Rectangle): number {
      return shape instanceof Shapes.Circle
        ? Math.PI * shape.radius ** 2
        : shape.width * shape.height;
    }

    export function calculatePerimeter(shape: Shapes.Circle | Shapes.Rectangle): number {
      return shape instanceof Shapes.Circle
        ? 2 * Math.PI * shape.radius
        : 2 * (shape.width + shape.height);
    }
  }
}

// The full path works...
const circle = new Geometry.Shapes.Circle(2);
const rect = new Geometry.Shapes.Rectangle(3, 4);

console.log(Geometry.Utilities.calculateArea(circle).toFixed(2));
console.log(Geometry.Utilities.calculateArea(rect));

// ...and an alias keeps it readable when the path gets long.
const Shapes = Geometry.Shapes;
const Utils = Geometry.Utilities;

const square = new Shapes.Rectangle(5, 5);
console.log('square area:', Utils.calculateArea(square));
console.log('square perimeter:', Utils.calculatePerimeter(square));

test('circle area', Number(Utils.calculateArea(circle).toFixed(2)), 12.57);
test('rectangle area', Utils.calculateArea(rect), 12);
test('circle perimeter', Number(Utils.calculatePerimeter(circle).toFixed(2)), 12.57);
test('rectangle perimeter', Utils.calculatePerimeter(rect), 14);
test('the alias points at the same class', square instanceof Geometry.Shapes.Rectangle, true);
test('square area via the alias', Utils.calculateArea(square), 25);

Output


      

Geometry.Utilities.calculateArea(...) is the argument against deep nesting all by itself. Note that the utilities refer to Shapes.Circle without the Geometry. prefix, because inside the outer namespace the sibling is already in scope. Also notice the circle’s area and perimeter are both 12.57 at radius 2 — a coincidence, not a bug.

Answers TS Modules 7 in the original set.

8 Export a namespace and use it from another file

fair

Write a namespace holding a logging function, export the namespace, and import it into another file to log messages.

Stuck? Three hints, smallest first
  1. 1. A namespace can be exported from a module like anything else.
  2. 2. Mixing namespaces and modules is usually a sign you want just one of them.
  3. 3. The modern equivalent is a module of named exports, imported with import * as Logger.

The real answer, across 2 files. Type-checked by the compiler at build time; not runnable in a browser, because a panel cannot contain import or export.

logger.ts

// A namespace, exported from a module.
export namespace Logger {
  const lines: string[] = [];

  export function logMessage(message: string): void {
    lines.push(message);
  }

  export function history(): readonly string[] {
    return lines;
  }
}

consumer.ts

import { Logger } from './logger.js';

Logger.logMessage('bus departed');
Logger.logMessage('bus arrived');

for (const line of Logger.history()) console.log(line);

// The modern spelling of the same thing, with no namespace at all:
//   // logger2.ts
//   const lines: string[] = [];
//   export function logMessage(m: string): void { lines.push(m); }
//   export function history(): readonly string[] { return lines; }
//
//   // consumer2.ts
//   import * as Logger2 from './logger2.js';
//   Logger2.logMessage('same effect, one concept fewer');

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The namespace itself needs no import, so this part runs as written.
namespace Logger {
  // Private to the namespace: no export, so nothing outside can reach it.
  const lines: string[] = [];

  export function logMessage(message: string): void {
    lines.push(message);
  }

  export function history(): readonly string[] {
    return lines;
  }

  export function clear(): void {
    lines.length = 0;
  }
}

Logger.clear();
Logger.logMessage('bus departed');
Logger.logMessage('bus arrived');

for (const line of Logger.history()) console.log(line);

// Logger.lines;   // error: Property 'lines' does not exist on type 'typeof Logger'

test('two messages were kept', Logger.history().length, 2);
test('in order', Logger.history()[0], 'bus departed');
test('and the second', Logger.history()[1], 'bus arrived');
test('clear empties it', (() => { Logger.clear(); return Logger.history().length; })(), 0);
test('the array itself is not reachable from outside', 'lines' in Logger, false);

Output


      

The commented block in consumer.ts is the point of the exercise. A module of named exports imported as import * as Logger gives you the identical calling style, Logger.logMessage(...), with one concept fewer to explain. If you find yourself exporting a namespace from a module, you almost certainly wanted just the module.

Answers TS Modules 8 in the original set.

9 Declaration merging: one namespace declared twice

hard

Declare a namespace holding an interface and a function. Declare the same namespace again with a second interface and function. Show that TypeScript merges the two and both are usable.

Stuck? Three hints, smallest first
  1. 1. Two blocks with the same namespace name merge into one. Interfaces with the same name merge too.
  2. 2. This is how a library adds to something you already have without editing it.
  3. 3. The merge is checked: two declarations that disagree about a member’s type are an error, not a silent overwrite.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// First declaration.
namespace UI {
  export interface Button {
    label: string;
  }

  export function createButton(label: string): Button {
    return { label };
  }
}

// Second declaration, same name. TypeScript merges them into one namespace.
namespace UI {
  export interface Panel {
    title: string;
    buttons: Button[];      // the sibling from the first block is in scope
  }

  export function createPanel(title: string, labels: string[]): Panel {
    return { title, buttons: labels.map((l) => createButton(l)) };
  }
}

// A third block, adding a member to the EXISTING Button interface.
// Interfaces of the same name merge as well, so Button now has two fields.
namespace UI {
  export interface Button {
    disabled?: boolean;
  }
}

const button = UI.createButton('Book now');
const panel = UI.createPanel('Seats', ['1A', '1B', '2A']);

// The merged field is available on the same interface.
const disabledButton: UI.Button = { label: 'Sold out', disabled: true };

console.log(button);
console.log(panel.title, 'has', panel.buttons.length, 'buttons');
console.log(panel.buttons.map((b) => b.label).join(', '));
console.log('merged field:', disabledButton.disabled);

test('the first block still works', button.label, 'Book now');
test('the second block sees the first', panel.buttons.length, 3);
test('panel title', panel.title, 'Seats');
test('button labels', panel.buttons.map((b) => b.label), ['1A', '1B', '2A']);
test('the third block added a field to Button', disabledButton.disabled, true);
test('and that field is optional, so old code is fine', UI.createButton('x').disabled, undefined);

Output


      

Two merges are happening. The three namespace UI blocks merge into one namespace, and the two interface Button declarations merge into one interface with both fields. The last assertion shows why the new field is optional: making it required would have broken createButton, which does not set it. This is exactly the mechanism used to add types to a library you do not control.

Answers TS Modules 9 in the original set.

The exercise list follows w3resource's TypeScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

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