Emmanuel Akinfulubi

Lesson 15 of 1817 min read

Modules and Declaration Files

Importing types, exporting them, and describing JavaScript that has no types of its own.

By the end: Split types across files and describe an untyped library.

Types cross file boundaries the same way values do, with one wrinkle worth understanding: because types are erased, an import that only carried types has to disappear as well. Getting that wrong causes a specific and confusing class of bug.

The second half of this lesson is about describing JavaScript that has no types of its own — declaration files, and how to fix a library whose types are wrong.

Exporting and importing types

types.ts — types are exported like anything else

export interface User {
  id: number;
  name: string;
}

export type Role = 'admin' | 'editor' | 'viewer';

// A value and a type can be exported side by side.
export const ROLES: readonly Role[] = ['admin', 'editor', 'viewer'];

export function isRole(value: unknown): value is Role {
  return typeof value === 'string' && (ROLES as readonly string[]).includes(value);
}

app.ts — and imported the same way

import { type User, type Role, ROLES, isRole } from './types.js';

function describe(user: User, role: Role): string {
  return `${user.name} is ${role}`;
}

const user: User = { id: 1, name: 'Ada' };

console.log(describe(user, 'admin'));
console.log(ROLES.join(', '));
console.log(isRole('editor'), isRole('nonsense'));

import type, and why it matters

An ordinary import of something that turns out to be a type has to be removed from the output, because there is nothing left to import. Usually the compiler works this out. Sometimes it cannot, and then you get a runtime error about a module that has no such export — or worse, an import kept alive purely for a type, which can drag a whole module into your bundle for nothing.

Three ways to import a type, and what each emits

// 1. An inline type modifier. Clear at a glance which names are types, and
//    those names are guaranteed to be erased.
import { type User, ROLES } from './types.js';

// 2. A type-only import statement. The whole statement disappears.
import type { Role } from './types.js';

// 3. A plain import. Works, but the compiler has to decide what to erase, and
//    a reader cannot tell which of these are types.
import { isRole } from './types.js';

function describe(user: User, role: Role): string {
  return isRole(role) ? `${user.name}: ${role}` : `${user.name}: unknown`;
}

console.log(describe({ id: 1, name: 'Ada' }, 'admin'), ROLES.length);

The circular import that only bites at run time

A worked example of why this is not pedantry. Two modules that reference each other's types are fine. Two modules that import each other's values may not be.

user.ts, importing a type from order.ts

// user.ts
import type { Order } from './order.js';

export interface User {
  id: number;
  orders: Order[];
}

export function orderCount(user: User): number {
  return user.orders.length;
}

order.ts — the other half of the cycle

import type { User } from './user.js';

export interface Order {
  id: string;
  placedBy: User;
}

export function ownerName(order: Order): number {
  return order.placedBy.id;
}

Re-exporting

parse.ts — an internal name we would rather not expose

export function internalParse(input: string): number {
  return Number.parseInt(input, 10);
}

index.ts — one entry point for a folder

// Re-export selected names.
export { type User, ROLES } from './types.js';

// Re-export everything a module has.
export * from './parse.js';

// Re-export with a rename, which is how you present a tidier public API than
// your internal file layout.
export { internalParse as parse } from './parse.js';

// Type-only re-export, for the same erasure reasons as before.
export type { Role } from './types.js';

Declaration files

A .d.ts file contains types and no implementation. It exists to describe JavaScript — either code that was never written in TypeScript, or the public shape of compiled output.

maths.d.ts — describing a JavaScript file

// `declare` says: this exists somewhere, here is its type, do not emit
// anything for it.
declare function add(a: number, b: number): number;

declare const VERSION: string;

declare class Calculator {
  constructor(precision: number);
  add(value: number): this;
  result(): number;
}

// In a .d.ts these still need exporting to be importable.
export { add, VERSION, Calculator };

Describing an untyped library

A minimal module declaration, to get moving

// If a package has no types at all, the compiler errors on the import. This
// silences it and gives you `any`, which is the fastest possible fix and
// leaves you with no checking.
declare module 'legacy-charts';

A better one, describing only what you actually use

// You do not have to describe the whole library. Ten minutes describing the
// three functions you call gives you real checking on the part you touch.
declare module 'legacy-charts' {
  export interface ChartOptions {
    width: number;
    height: number;
    title?: string;
  }

  export class Chart {
    constructor(element: HTMLElement, options: ChartOptions);
    render(data: readonly number[]): void;
    destroy(): void;
  }

  export function version(): string;
}

Declaring globals

globals.d.ts — for things that are simply there

// A global injected by a build tool or a script tag.
declare global {
  const BUILD_VERSION: string;

  interface Window {
    analytics?: {
      track(event: string, payload?: Record<string, unknown>): void;
    };
  }
}

// A file containing top-level import or export is a module, and `declare
// global` inside a module needs this to work. In a file with no imports or
// exports, the declarations are already global and this is unnecessary.
export {};

And how you use them

function report(event: string): string {
  // Optional, so it must be checked — which is right, because a script may
  // not have loaded.
  window.analytics?.track(event, { version: BUILD_VERSION });
  return `reported ${event} on build ${BUILD_VERSION}`;
}

console.log(typeof report);

Augmenting somebody else's types

The most practically useful thing in this lesson. Interfaces merge, as lesson 03 showed, and that is the mechanism for adding a property to a type you do not own.

Adding a field to a framework's request object

// The canonical case: middleware attaches `user` to every request, and you
// want the rest of your code to know about it.
declare module 'express-serve-static-core' {
  interface Request {
    user?: {
      id: number;
      roles: readonly string[];
    };
  }
}

export {};

Teaching the compiler about non-code imports

// Without this, `import styles from './button.module.css'` is an error,
// because the compiler has no idea what a CSS file is.
declare module '*.module.css' {
  const classes: Record<string, string>;
  export default classes;
}

declare module '*.svg' {
  const url: string;
  export default url;
}

export {};

Where the compiler looks for types

Four places, in rough order of how often they are the answer.

  1. The package's own types, via a types or exports field in its package.json. Modern packages ship these.
  2. @types/name in node_modules, installed separately.
  3. Your own .d.ts files, anywhere the include in your tsconfig reaches.
  4. The lib setting, for built-in JavaScript and DOM types. This is why document is unknown until you add "dom", and why a modern array method may be missing until you raise the version.

lib decides what built-ins exist

// This course compiles with lib: ["es2023", "dom"], so all of these resolve.

// es2015 and later.
console.log('Map:', new Map([['a', 1]]).get('a'));

// es2019.
console.log('flat:', [1, [2, [3]]].flat(2));

// es2022.
console.log('at:', [1, 2, 3].at(-1));
console.log('hasOwn:', Object.hasOwn({ a: 1 }, 'a'));

// es2023.
console.log('findLast:', [1, 2, 3, 4].findLast((n) => n % 2 === 1));
console.log('toSorted:', [3, 1, 2].toSorted((a, b) => a - b));

// From dom.
console.log('URL is available:', typeof URL);

Output


      

Namespaces, and why to skip them

Before ES modules existed, TypeScript had its own module system spelled namespace. You will see it in older code and in some declaration files.

What it looks like, so you recognise it

namespace Geometry {
  export interface Point {
    x: number;
    y: number;
  }

  export function distance(a: Point, b: Point): number {
    return Math.hypot(a.x - b.x, a.y - b.y);
  }
}

const corner: Geometry.Point = { x: 0, y: 0 };
console.log(Geometry.distance(corner, { x: 3, y: 4 }));

A note on module resolution errors

Three failures account for most import problems, and all three look similar.

Your turn

  1. Create two files, export an interface from one and import it into the other. Then switch to import type and confirm by looking at the compiled output that the import statement has vanished.
  2. Create a genuine type-only cycle: two files that each import a type from the other. Confirm it compiles and that the emitted JavaScript contains no imports at all.
  3. Write a declare module 'some-package' for a package you have not installed, describing one function. Import and call it, and confirm the call is checked.
  4. Add a declare global block declaring a const your build would inject. Use it, and note that no import was needed.
  5. Remove "dom" from your lib and try to use document. Read the error, then put it back.
  6. Set lib to ["es5"] and try [1,2,3].at(-1). Note that the failure is a type error, not a runtime one — and that the reverse mistake would have been a runtime error with no warning.

Recap

  • Types are exported and imported like values, but because they are erased, the import has to be erased too.
  • Prefer the inline modifier — import { type User, ROLES } — so the distinction is visible and the erasure is guaranteed rather than inferred. verbatimModuleSyntax makes it required.
  • A cycle made only of type imports does not exist at run time. A cycle of value imports does, and produces the half-initialised-module bug.
  • Under modern resolution you import './types.js' to reach types.ts, because the specifier has to work in the emitted output.
  • A .d.ts file describes JavaScript without implementing it. Look for @types/name before writing one, and describe only the parts you use.
  • declare module augments a library's types by interface merging — useful, and easy to overuse.
  • lib decides which built-ins the compiler believes exist. It ships no code, so a lib ahead of your runtime turns a compile error into a runtime one.
  • Do not write new namespace blocks. ES modules do the job, and namespaces emit runtime objects.

Saved in this browser only.

Newsletter

New writing, when it lands.

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