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
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);
}
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.
// 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
import type { Order } from './order.js';
export interface User {
id: number;
orders: Order[];
}
export function orderCount(user: User): number {
return user.orders.length;
}
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
export function internalParse(input: string): number {
return Number.parseInt(input, 10);
}
// 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.
// `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
// 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';
// 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
// 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 {};
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.
// 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 {};
// 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.
- The package's own types, via a
typesorexportsfield in itspackage.json. Modern packages ship these. @types/nameinnode_modules, installed separately.- Your own
.d.tsfiles, anywhere theincludein your tsconfig reaches. - The
libsetting, for built-in JavaScript and DOM types. This is whydocumentis unknown until you add"dom", and why a modern array method may be missing until you raise the version.
// 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.
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.
- Cannot find module 'x' or its corresponding type declarations. The package has no types. Install
@types/x, or write adeclare module. - Resolves in the editor, fails at run time. Almost always an extension problem — you wrote
./typeswhere the runtime needs./types.js. CheckmoduleResolution. - A default import that is undefined at run time. A CommonJS package being imported as though it had a real default export.
esModuleInteropexists for this, and lesson 18 covers it.
Your turn
- Create two files, export an interface from one and import it into the other. Then switch to
import typeand confirm by looking at the compiled output that the import statement has vanished. - 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.
- 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. - Add a
declare globalblock declaring aconstyour build would inject. Use it, and note that no import was needed. - Remove
"dom"from yourliband try to usedocument. Read the error, then put it back. - Set
libto["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.verbatimModuleSyntaxmakes 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 reachtypes.ts, because the specifier has to work in the emitted output. - A
.d.tsfile describes JavaScript without implementing it. Look for@types/namebefore writing one, and describe only the parts you use. declare moduleaugments a library's types by interface merging — useful, and easy to overuse.libdecides which built-ins the compiler believes exist. It ships no code, so alibahead of your runtime turns a compile error into a runtime one.- Do not write new
namespaceblocks. ES modules do the job, and namespaces emit runtime objects.