TypeScript · lesson 13 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Modules and Declaration Files in TypeScript
ES modules under nodenext, import type, barrels, .d.ts files for untyped JS, @types, globals, path aliases and project references, with real output.
By SkillByExample · Updated
Examples run on TypeScript 6.0.3 and Node 22 in a folder whose package.json has "type": "module", with typescript and tsx installed. Each section is its own small project directory with its own tsconfig.json, compiled with npx tsc -p <dir> and run with node.
ES Modules
TypeScript uses standard import and export. Under "module": "nodenext" — the setting for code that Node runs directly — the compiler applies Node’s own resolution rules.
{
"compilerOptions": {
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"verbatimModuleSyntax": true
}
}
export interface Vector2D {
x: number;
y: number;
}
export function add(a: Vector2D, b: Vector2D): Vector2D {
return { x: a.x + b.x, y: a.y + b.y };
}
export default function length(v: Vector2D): number {
return Math.hypot(v.x, v.y);
}
import length, { add, Vector2D } from "./math";
const v: Vector2D = add({ x: 1, y: 2 }, { x: 2, y: 2 });
console.log(v, length(v));
$ npx tsc -p esm
esm/src/main.ts(1,39): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './math.js'?
Node’s ES module loader never guesses extensions, so the import must name the file that will exist after compilation: ./math.js, even though you wrote math.ts. Fix the path and the compiler moves on to the next problem:
import length, { add, Vector2D } from "./math.js";
const v: Vector2D = add({ x: 1, y: 2 }, { x: 2, y: 2 });
console.log(v, length(v));
$ npx tsc -p esm
esm/src/main.ts(1,23): error TS1484: 'Vector2D' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
Vector2D is only a type, and types are erased. verbatimModuleSyntax keeps every import exactly as written except those marked type, so without the marker the emitted JavaScript would ask math.js for an export that doesn’t exist, and Node would throw a SyntaxError at load time. The option turns that into a compile error. The fixed import:
import length, { add, type Vector2D } from "./math.js";
const v: Vector2D = add({ x: 1, y: 2 }, { x: 2, y: 2 });
console.log(v, length(v));
$ npx tsc -p esm
$ cat esm/dist/main.js
import length, { add } from "./math.js";
const v = add({ x: 1, y: 2 }, { x: 2, y: 2 });
console.log(v, length(v));
$ node esm/dist/main.js
{ x: 3, y: 4 } 5
The emitted import has no trace of Vector2D. tsx resolves ./math.js to math.ts too, so the same source runs unchanged under npx tsx esm/src/main.ts.
Module Resolution: nodenext versus bundler
moduleResolution decides how an import path becomes a file. nodenext mirrors Node exactly. bundler mirrors Vite, webpack and esbuild, which do accept extensionless paths:
{
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"noEmit": true
}
}
export const double = (n: number) => n * 2;
import { double } from "./math";
console.log(double(21));
$ npx tsc -p bundler
$ node bundler/src/main.ts 2>&1 | grep -m1 Error
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/root/ts/modules/bundler/src/math' imported from /root/ts/modules/bundler/src/main.ts
The type check passes — but only because the compiler was told a bundler would run first. Hand the same file straight to Node (which strips the types itself in version 22) and it can’t find ./math. Pick the setting that matches what actually executes your code: nodenext for Node, bundler for bundled front-end code.
Re-exporting with Barrel Files
An index.ts that re-exports from sibling files gives consumers a single import path.
export interface User { id: number; name: string }
export const guest: User = { id: 0, name: "guest" };
import type { User } from "./user.js";
export interface Post { title: string; author: User }
export { guest } from "./user.js";
export type { User } from "./user.js";
export type { Post } from "./post.js";
import { guest, type Post } from "./models/index.js";
const post: Post = { title: "Hello", author: guest };
console.log(post);
{
"compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist", "verbatimModuleSyntax": true }
}
$ npx tsc -p barrel
$ node barrel/dist/main.js
{ title: 'Hello', author: { id: 0, name: 'guest' } }
$ cat barrel/dist/models/index.js
export { guest } from "./user.js";
$ cat barrel/dist/models/post.js
export {};
The compiled barrel only re-exports guest; the export type lines vanished, and post.js, which only held an interface, compiled to an empty module. Barrels are convenient, but importing one loads every module it re-exports — in large apps that can slow startup and defeat tree-shaking, so keep them small.
Namespaces
Namespaces are TypeScript’s pre-ES-module way of grouping code under one name. They compile to an object built by a function.
namespace Validation {
export interface Validator {
ok(s: string): boolean;
}
export const lettersOnly: Validator = { ok: (s) => /^[A-Za-z]+$/.test(s) };
export const zip: Validator = { ok: (s) => /^\d{5}$/.test(s) };
}
for (const input of ["Hello", "98052", "abc123"]) {
console.log(input, Validation.lettersOnly.ok(input), Validation.zip.ok(input));
}
$ npx tsx namespace.ts
Hello true false
98052 false true
abc123 false false
$ node namespace.ts 2>&1 | grep -m1 Error
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript namespace declaration is not supported in strip-only mode
A namespace with values generates code, so Node’s built-in type stripping rejects it just as it rejects enums and parameter properties. In new code use modules; you’ll still meet namespace inside .d.ts files and in declare global blocks.
Declaration Files for Untyped JavaScript
When a JavaScript package ships no types, TypeScript can’t check your use of it. Here’s a tiny local package with no .d.ts:
export function createClient(apiKey) {
return {
async charge({ amount, currency }) {
if (!Number.isInteger(amount)) throw new Error("amount must be in cents");
return { id: "ch_1", status: "succeeded", amount, currency, key: apiKey.slice(0, 3) };
},
};
}
{ "name": "payment-sdk", "type": "module", "exports": "./index.js" }
import { createClient } from "payment-sdk";
const client = createClient("sk_test_123");
const result = await client.charge({ amount: 1999, currency: "USD" });
console.log(result.status, result.amount);
{
"compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist" }
}
$ npx tsc -p sdk
sdk/src/main.ts(1,30): error TS7016: Could not find a declaration file for module 'payment-sdk'. '/root/ts/modules/sdk/node_modules/payment-sdk/index.js' implicitly has an 'any' type.
Under strict, an untyped import is an error (noImplicitAny) rather than a silent any. Write a declaration file describing the API — types only, no code:
declare module "payment-sdk" {
export type Currency = "USD" | "EUR" | "GBP";
export interface ChargeOptions {
/** Amount in the smallest currency unit, e.g. cents. */
amount: number;
currency: Currency;
}
export interface ChargeResult {
id: string;
status: "succeeded" | "failed" | "pending";
amount: number;
currency: Currency;
}
export function createClient(apiKey: string): {
charge(options: ChargeOptions): Promise<ChargeResult>;
};
}
$ npx tsc -p sdk
$ node sdk/dist/main.js
succeeded 1999
tsc picked up the .d.ts because it’s inside the project, and now currency: "JPY" or a missing amount would be errors. The declaration is a promise you make about the JavaScript: the compiler can’t check that the real charge returns a ChargeResult, so keep declarations small and accurate. The comment on amount shows up in editor tooltips.
@types Packages and the types Option
Popular untyped libraries have community declarations on DefinitelyTyped, installed as @types/<name>. Node’s own globals come from @types/node:
const port = Number(process.env.PORT ?? 3000);
console.log(`port ${port}, node ${process.version}`);
{
"compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist" }
}
$ npx tsc -p types-demo
types-demo/src/env.ts(1,21): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
types-demo/src/env.ts(2,35): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
@types/node is installed, but TypeScript 6 changed the default of types to an empty list, so installed @types packages are no longer loaded automatically (TypeScript 5 loaded every one it found). List the ones you want:
{
"compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist", "types": ["node"] }
}
$ npx tsc -p types-demo
$ PORT=8080 node types-demo/dist/env.js
port 8080, node v22.22.2
Ambient Declarations and Global Augmentation
declare tells the compiler that something exists without creating it — for example, a constant a build tool injects. declare global adds to global types such as Array.
declare const __APP_VERSION__: string;
declare global {
interface Array<T> {
last(): T | undefined;
}
}
Array.prototype.last = function () {
return this[this.length - 1];
};
console.log([1, 2, 3].last(), [].last());
console.log(`version ${__APP_VERSION__}`);
{
"compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist" }
}
$ npx tsc -p globals
$ node globals/dist/main.js 2>&1 | head -6
3 undefined
file:///root/ts/modules/globals/dist/main.js:5
console.log(`version ${__APP_VERSION__}`);
^
ReferenceError: __APP_VERSION__ is not defined
It compiles cleanly and then fails: declare const only describes __APP_VERSION__; nothing defined it, because no build tool ran. That’s the nature of every ambient declaration — the compiler trusts it. The Array augmentation worked because the file both declared last and assigned it. (Patching built-in prototypes is shown here to illustrate the mechanism; in application code a plain last(arr) function avoids clashing with future JavaScript methods.)
Path Aliases
paths maps import aliases to directories. It changes how the compiler resolves imports, not the emitted code:
{
"compilerOptions": {
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"paths": { "@utils/*": ["./src/utils/*"] }
}
}
export const money = (cents: number) => `$${(cents / 100).toFixed(2)}`;
import { money } from "@utils/format.js";
console.log(money(1999));
$ npx tsc -p paths
$ grep import paths/dist/main.js
import { money } from "@utils/format.js";
$ node paths/dist/main.js 2>&1 | grep -m1 Error
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@utils/format.js' imported from /root/ts/modules/paths/dist/main.js
tsc resolved the alias and emitted the import unchanged, so Node looks for a package called @utils and fails. Bundlers and loaders must be configured with the same aliases separately. For code Node runs directly, use Node’s own subpath imports in package.json instead — both Node and TypeScript understand them:
{ "type": "module", "imports": { "#utils/*": "./dist/utils/*" } }
export const money = (cents: number) => `$${(cents / 100).toFixed(2)}`;
import { money } from "#utils/format.js";
console.log(money(1999));
{
"compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist" }
}
$ npx tsc -p subpath
$ node subpath/dist/main.js
$19.99
TypeScript maps ./dist/utils/* back to ./src/utils/* using rootDir and outDir, so the same specifier type-checks against the source and runs against the output.
Project References
In a monorepo, project references split the code into separately built projects. composite projects emit declarations, and tsc --build rebuilds only what changed.
{
"compilerOptions": { "composite": true, "module": "nodenext", "rootDir": "src", "outDir": "dist" }
}
export const slugify = (s: string) => s.toLowerCase().trim().replace(/\W+/g, "-");
{
"compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist" },
"references": [{ "path": "../utils" }]
}
import { slugify } from "../../utils/dist/index.js";
console.log(slugify(" Hello, TypeScript World "));
$ npx tsc --build refs/app --verbose
6:35:27 PM - Projects in this build:
* refs/utils/tsconfig.json
* refs/app/tsconfig.json
6:35:27 PM - Project 'refs/utils/tsconfig.json' is out of date because output file 'refs/utils/tsconfig.tsbuildinfo' does not exist
6:35:27 PM - Building project '/root/ts/modules/refs/utils/tsconfig.json'...
6:35:28 PM - Project 'refs/app/tsconfig.json' is out of date because output file 'refs/app/tsconfig.tsbuildinfo' does not exist
6:35:28 PM - Building project '/root/ts/modules/refs/app/tsconfig.json'...
$ node refs/app/dist/main.js
hello-typescript-world
$ npx tsc --build refs/app --verbose
6:35:30 PM - Projects in this build:
* refs/utils/tsconfig.json
* refs/app/tsconfig.json
6:35:30 PM - Project 'refs/utils/tsconfig.json' is up to date because newest input 'refs/utils/src/index.ts' is older than output 'refs/utils/tsconfig.tsbuildinfo'
6:35:30 PM - Project 'refs/app/tsconfig.json' is up to date because newest input 'refs/app/src/main.ts' is older than output 'refs/app/tsconfig.tsbuildinfo'
The first build compiles utils before app, because app references it. The second build does nothing: both projects’ outputs are newer than their sources. The tsconfig.tsbuildinfo file that tsc writes for each project records what it built, so this check doesn’t have to type-check anything. In a real monorepo the import would be a workspace package name (@acme/utils) rather than a relative path into dist.
Practice
1. Turn on "declaration": true for the esm project and look at what tsc writes for math.ts. What would a JavaScript consumer of this package get from it?
Solution
{
"compilerOptions": {
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"verbatimModuleSyntax": true,
"declaration": true
}
}
$ npx tsc -p esm
$ ls esm/dist
main.d.ts
main.js
math.d.ts
math.js
$ cat esm/dist/math.d.ts
export interface Vector2D {
x: number;
y: number;
}
export declare function add(a: Vector2D, b: Vector2D): Vector2D;
export default function length(v: Vector2D): number;
Each .ts file now produces a .js file and a .d.ts file. The declaration file is the same kind of file you’d write by hand for an untyped library: signatures only, no bodies. Publishing it next to the JavaScript (and pointing "types" or "exports" at it in package.json) is how a TypeScript library gives its consumers types without shipping .ts source.
2. Add a refund(chargeId: string): Promise<ChargeResult> declaration to payment-sdk.d.ts and call it. What happens, and why doesn’t tsc catch it?
Solution
declare module "payment-sdk" {
export type Currency = "USD" | "EUR" | "GBP";
export interface ChargeOptions { amount: number; currency: Currency }
export interface ChargeResult {
id: string;
status: "succeeded" | "failed" | "pending";
amount: number;
currency: Currency;
}
export function createClient(apiKey: string): {
charge(options: ChargeOptions): Promise<ChargeResult>;
refund(chargeId: string): Promise<ChargeResult>;
};
}
import { createClient } from "payment-sdk";
const client = createClient("sk_test_123");
try {
const refund = await client.refund("ch_1");
console.log(refund.status);
} catch (e) {
console.log((e as Error).message);
}
$ npx tsc -p sdk
$ node sdk/dist/refund.js
client.refund is not a function
A clean compile and a runtime failure: the real JavaScript has no refund method. tsc can’t compare a .d.ts against the code it describes — declarations are unchecked claims, and a wrong one is worse than none, because it removes the error that would have warned you.
3. Change the implementation of slugify in the refs project — not its signature — and rebuild with --verbose. Which projects are rebuilt?
Solution
export const slugify = (s: string) => s.toLowerCase().trim().replace(/\W+/g, "_");
$ npx tsc --build refs/app --verbose
6:35:35 PM - Projects in this build:
* refs/utils/tsconfig.json
* refs/app/tsconfig.json
6:35:35 PM - Project 'refs/utils/tsconfig.json' is out of date because output 'refs/utils/tsconfig.tsbuildinfo' is older than input 'refs/utils/src/index.ts'
6:35:35 PM - Building project '/root/ts/modules/refs/utils/tsconfig.json'...
6:35:36 PM - Project 'refs/app/tsconfig.json' is up to date with .d.ts files from its dependencies
6:35:36 PM - Updating output timestamps of project '/root/ts/modules/refs/app/tsconfig.json'...
$ node refs/app/dist/main.js
hello_typescript_world
Only utils. Its .d.ts output didn’t change — the signature is still (s: string) => string — so app is “up to date with .d.ts files from its dependencies” and just gets its timestamps bumped. The output still changes to underscores, because app imports the compiled utils at runtime. That’s the saving project references buy in a big monorepo: implementation-only changes don’t cascade into rebuilding every dependent project.