Libraries and large projects Advanced
Declaration files
Type an untyped JavaScript naira formatter with a .d.ts file, declare modules and globals, augment existing interfaces, install @types and generate declarations.
BY THE END OF THIS LESSON YOU CAN
- Explain what a .d.ts file is, how the compiler finds one, and why nothing checks it against the JavaScript
- Write a declaration file for an untyped JavaScript module, including options, overloads and a default export
- Declare a bare package with declare module, and choose between a hand-written declaration and an @types package
- Add global declarations and augment existing interfaces in other modules safely
- Generate declarations from TypeScript and from JSDoc-annotated JavaScript, and know what isolatedDeclarations demands
₦2,500 became ₦25
A shop's checkout has used the same money formatter for years. A developer wrote it in plain JavaScript long before the team adopted TypeScript, and it lives in the repository as vendor/naira-format/index.js. It takes an amount in kobo (₦1 is 100 kobo, and money is stored in whole kobo so that no fractions of a naira get lost):
export const CURRENCY = "NGN";
export function formatNaira(kobo, options = {}) {
const { symbol = true, decimals = 2 } = options;
const text = (kobo / 100).toLocaleString("en-NG", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return symbol ? `₦${text}` : text;
}
export function parseNaira(text) {
const cleaned = text.replace(/[₦,\s]/g, "");
if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) return null;
return Math.round(Number(cleaned) * 100);
}
export default formatNaira;
When the new TypeScript checkout imports it, tsc complains straight away:
npx tsc --noEmit checkout.ts:1:29 - error TS7016: Could not find a declaration file for module './vendor/naira-format/index.js'. '/home/you/naira-shop/vendor/naira-format/index.js' implicitly has an 'any' type. 1 import { formatNaira } from "./vendor/naira-format/index.js"; ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Found 1 error in checkout.ts:1
The compiler can see that the file exists, but it cannot see any types in it: the project does not type-check JavaScript files. Under strict, it refuses to treat the whole module as any silently. Someone in a hurry makes the red line go away with // @ts-ignore, and ships the price label that reads the amount from a form field:
// @ts-ignore -- the formatter has no types
import { formatNaira } from "./vendor/naira-format/index.js";
const priceFromForm = "2500";
console.log(`Total: ${formatNaira(priceFromForm)}`);
npx tsx checkout.ts and of the browser terminalTotal: ₦25.00
The form gave naira as text. The formatter expects kobo as a number. JavaScript turned "2500" / 100 into 25 without a word, and a ₦2,500 order was shown as ₦25. TypeScript would have caught this in a second if it had known what formatNaira accepts. @ts-ignore did not add that knowledge; it switched the checking off, and every value coming out of the library became any.
This lesson is about giving the compiler that knowledge for code it cannot read: declaration files. You will write one by hand for the formatter, declare a real untyped npm package, install community types, add global names, extend other people's interfaces, and finally generate declarations instead of writing them.
What a declaration file is
A declaration file is a file ending in .d.ts that contains only types: the names a module exports and the shape of each one, with no function bodies and no values. It answers one question for the compiler: "what is in this JavaScript, and how may it be used?" You already met the output kind in What the TypeScript compiler does: tsc --declaration writes one next to every .js file it emits. Every package you have installed with types works the same way.
Declaration files come from three places:
| Source | Example | Who keeps it in step with the JavaScript |
|---|---|---|
Generated by tsc from TypeScript (or from JSDoc in JavaScript) | Every @zudojs/* package's dist/index.d.ts | The compiler, on every build |
Published separately in an @types/* package | @types/node, @types/ms | Volunteers at DefinitelyTyped |
| Written by hand, in your own project | vendor/naira-format/index.d.ts | You |
The keyword that makes a declaration is declare. It means "this exists at runtime; I am only telling you its type". A declare statement produces no JavaScript at all, and inside a .d.ts file everything is implicitly a declaration. The part of a program where only declarations are allowed is called an ambient context, and an ambient context may not contain code. Try to sneak a function body into one and the compiler refuses, but only if it is allowed to look:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": false,
"types": ["node"],
"noEmit": true,
"verbatimModuleSyntax": true
}
}
export declare const VAT_RATE: number;
export declare function addVat(kobo: number): number {
return Math.round(kobo * 1.075);
}
npx tsc --noEmit printsmoney.d.ts:3:54 - error TS1183: An implementation cannot be declared in ambient contexts.
3 export declare function addVat(kobo: number): number {
~
Found 1 error in money.d.ts:3Look at "skipLibCheck": false in that tsconfig.json. The course's usual setting is true, and with it the compiler skips type-checking every .d.ts file, not just the ones in node_modules. That makes big projects faster (you will measure how much in Compiler performance), but it also means mistakes inside your own declaration files go unreported. While you write declarations, check them once with npx tsc --noEmit --skipLibCheck false.
How the compiler finds a declaration
For a relative import such as "./vendor/naira-format/index.js", the compiler looks for a declaration file with the same name next to the JavaScript file: index.d.ts beside index.js. (For .mjs it looks for .d.mts, for .cjs it looks for .d.cts.) If it finds one, it uses the types in it and never opens the JavaScript. For a package name such as "ms", it follows the package's package.json ("exports" with a "types" condition, or a top-level "types" field), then falls back to node_modules/@types/ms. You will use both routes in this lesson, and Debugging TypeScript shows how to watch the search step by step.
Before you write a single type
REASON IT OUT
What does naira-format really accept?
A declaration file is a description of JavaScript that already exists. Get the description wrong and the compiler will enforce the wrong rules with total confidence. Read the formatter's code above and answer, before looking at any types:
- What does
formatNairado with2500.5, a kobo amount with a fraction? With a negative amount? - What happens if you pass
formatNairaabigint, such as250000n? - What does
parseNairareturn for text that is not an amount, like"two thousand"? For"-5"? - What happens if
parseNairareceives a number instead of text? - Which of these facts can a type express, and which can only be documented or checked at runtime?
Show the reasoning
Run the library on those inputs rather than guessing. A plain .js file is the right tool for probing: it runs without any types getting in the way.
import { formatNaira, parseNaira } from "./vendor/naira-format/index.js";
console.log(formatNaira(2500.5), formatNaira(-150000));
for (const input of [250000n, 1250]) {
try {
console.log(input, formatNaira(input), parseNaira(input));
} catch (error) {
console.log(error.message);
}
}
console.log(parseNaira("two thousand"), parseNaira("-5"), parseNaira(" ₦ 1,250.50 "));
node probe.js and of the browser terminal₦25.01 ₦-1,500.00 Cannot mix BigInt and other types, use explicit conversions text.replace is not a function null null 125050
- A fraction of a kobo is rounded into the display (
₦25.01), so the caller should only pass whole kobo. A negative amount prints as₦-1,500.00, which is ugly but does not crash. - A
bigintthrows:250000n / 100mixes a bigint with a number. The declaration must saynumber, notnumber | bigint. nullfor anything that is not a positive amount with at most two decimals, including negative text. The return type must benumber | null, so that callers are forced to handle thenull.- It throws a
TypeError. The parameter must bestring. - Types can say
numbervsstringvsbigintand "may be null". They cannot say "a whole number of kobo" (a branded type can get close) or "not negative". Those go in the documentation comment, and in a runtime check at the edge of your program.
Typing the naira formatter
Now write the description. Create vendor/naira-format/index.d.ts beside the JavaScript file:
/** The currency every function in this library works in. */
export declare const CURRENCY: "NGN";
export interface FormatOptions {
/** Put the ₦ sign in front. Default: true. */
symbol?: boolean;
/** Digits after the decimal point. Default: 2. */
decimals?: number;
}
/**
* Formats an amount of kobo (₦1 = 100 kobo) as naira text.
* Pass whole kobo: fractions are rounded into the display.
*/
export declare function formatNaira(kobo: number, options?: FormatOptions): string;
/** Reads text such as "₦1,250.50" back into kobo, or returns null if it is not an amount. */
export declare function parseNaira(text: string): number | null;
export default formatNaira;
Line by line:
export declare const CURRENCY: "NGN": the value exists in the JavaScript; its type is the literal"NGN", which is more precise thanstringand true, because the JavaScript assigns a constant.export interface FormatOptions: an interface needs nodeclare, because it is a type and has no runtime part anyway. Exporting it lets callers write their own option objects with the right type.- The functions have signatures and no bodies.
options?matches the JavaScript defaultoptions = {}. export default formatNairasays the default export is that same function, which is what the JavaScript does.- The
/** … */comments are JSDoc comments. Editors show them when you hover over a name, so the rule "pass whole kobo" appears exactly where a caller needs it.
Take the @ts-ignore out of the checkout and run the checker:
import { formatNaira } from "./vendor/naira-format/index.js";
const priceFromForm = "2500";
console.log(`Total: ${formatNaira(priceFromForm)}`);
npx tsc --noEmit printscheckout.ts:4:35 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
4 console.log(`Total: ${formatNaira(priceFromForm)}`);
~~~~~~~~~~~~~
Found 1 error in checkout.ts:4That is the bug from the first section, found before anything ran. The fix is to convert the form text on purpose, with the library's own parser, and to deal with the null it can return:
import formatNaira, { CURRENCY, parseNaira, type FormatOptions } from "./vendor/naira-format/index.js";
function priceLabel(fromForm: string, options?: FormatOptions): string {
const kobo = parseNaira(fromForm);
if (kobo === null) return `Invalid amount: "${fromForm}"`;
return `Total: ${formatNaira(kobo, options)} (${CURRENCY})`;
}
console.log(priceLabel("2500"));
console.log(priceLabel("₦1,250,000.50", { decimals: 0 }));
console.log(priceLabel("two thousand"));
npx tsx checkout.ts and of the browser terminalTotal: ₦2,500.00 (NGN) Total: ₦1,250,001 (NGN) Invalid amount: "two thousand"
Every import works: the default import, the named ones, and type FormatOptions, which exists only in the declaration file. If you forget the kobo === null check, the compiler now reports 'kobo' is possibly 'null' (TS18047), because the declaration said number | null.
Declarations are promises nobody checks
The compiler read index.d.ts and never opened index.js. That is the whole point of a declaration file, and also its danger: nothing compares a hand-written declaration with the JavaScript it describes. Suppose a teammate wants to format big totals and "improves" the declaration without touching the code:
export declare function formatNaira(kobo: number | bigint): string;
export function formatNaira(kobo) {
return `₦${(kobo / 100).toLocaleString("en-NG", { minimumFractionDigits: 2 })}`;
}
import { formatNaira } from "./vendor/naira-format/index.js";
const yearlyRevenue = 9_007_199_254_740_993n;
try {
console.log(formatNaira(yearlyRevenue));
} catch (error) {
console.log(`${(error as Error).name}: ${(error as Error).message}`);
}
npx tsx report.ts and of the browser terminalTypeError: Cannot mix BigInt and other types, use explicit conversions
The type check passed. The program crashed. The same thing happens with any @types package that has fallen behind its library, or was never quite right. Three habits keep declarations honest:
- Describe what the code does, not what you wish it did. If you need bigint support, change the JavaScript first, then the declaration.
- Test the JavaScript through the declaration. A small test file that calls every declared function with the declared types, and checks the results at runtime, fails the moment the two disagree. You will write these in Testing TypeScript.
- Prefer generated declarations. When the source is TypeScript, or JavaScript with JSDoc types, let the compiler write the
.d.ts. The last part of this lesson shows how.
Untyped npm packages: declare module
The formatter lives in your repository, so you could put its declaration file right beside it. A package in node_modules is different: you must never edit files there, because the next npm install replaces them. Take ms, a small, very popular package that converts durations such as "2h" into milliseconds. It ships no types:
import ms from "ms";
const sessionLength = ms("2h");
console.log(sessionLength);
npx tsc --noEmit printssession.ts:1:16 - error TS7016: Could not find a declaration file for module 'ms'. 'node_modules/ms/index.js' implicitly has an 'any' type.
Try `npm i --save-dev @types/ms` if it exists or add a new declaration (.d.ts) file containing `declare module 'ms';`
1 import ms from "ms";
~~~~
Found 1 error in session.ts:1The message offers two ways out. The second one is an ambient module declaration: a declare module "name" { … } block in any .d.ts file of your project, which says "whenever someone imports "name", these are its types". The file can live anywhere the compiler includes; a types/ folder is the usual home.
The shorthand, and why it is a trap
The quickest version has no body at all:
declare module "ms";
import ms from "ms";
const retryAfter = ms("two hours");
console.log(retryAfter, typeof retryAfter);
npx tsx session.tsundefined undefined
This is a shorthand ambient module: every import from it is any. The error goes away and so does all checking. ms quietly returns undefined for text it does not understand, and nothing warned you. The shorthand is acceptable for one afternoon while you migrate a large JavaScript project; it is not a fix.
A real declaration for a CommonJS package
Before writing the body, look at how ms exports itself. Its index.js is CommonJS and ends with module.exports = function (val, options) { … }: the module is the function. That shape is written export = in a declaration, and an ES module imports it as the default export. Two overloads (several signatures for one function, covered in Advanced functions) describe its two jobs:
declare module "ms" {
interface FormatOptions {
/** "2 hours" instead of "2h". */
long?: boolean;
}
/** Parses a duration such as "2h" or "1.5d" into milliseconds (undefined if it cannot). */
function ms(value: string): number | undefined;
/** Formats milliseconds as a short duration such as "2h". */
function ms(value: number, options?: FormatOptions): string;
export = ms;
}
import ms from "ms";
const sessionLength = ms("2h");
if (sessionLength === undefined) throw new Error("bad duration");
console.log(sessionLength, ms(sessionLength), ms(90_000, { long: true }));
console.log(ms("two hours"));
npx tsx session.ts7200000 2h 2 minutes undefined
Notice number | undefined on the parsing overload. That was the result of reading the code and running it on bad text, exactly as in the reasoning above. Now a careless caller is stopped:
import ms from "ms";
const lockoutMs: number = ms("15m");
const label: number = ms(900_000);
npx tsc --noEmit printslimits.ts:3:7 - error TS2322: Type 'number | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.
3 const lockoutMs: number = ms("15m");
~~~~~~~~~
limits.ts:4:7 - error TS2322: Type 'string' is not assignable to type 'number'.
4 const label: number = ms(900_000);
~~~~~
Found 2 errors in the same file, starting at: limits.ts:3NOTE
Adeclare module "ms" block only works in a file that is itself a script, that is, a .d.ts file with no top-level import or export. Inside a module file, the same syntax means something else: an augmentation of an existing module, which you will meet below.@types packages and DefinitelyTyped
The error's first suggestion was npm i --save-dev @types/ms. DefinitelyTyped is a large community repository of hand-written declarations for JavaScript packages that do not ship their own. Each one is published to npm as @types/<package>. The compiler looks in node_modules/@types automatically, so installing is all you do. In a fresh project, with no declaration of your own:
npm install ms npm install -D @types/ms npx tsc --noEmit app.ts:4:23 - error TS2769: No overload matches this call. The last overload gave the following error. Argument of type '"two hours"' is not assignable to parameter of type 'StringValue'. 4 const retryAfter = ms("two hours"); ~~~~~~~~~~~ node_modules/@types/ms/index.d.ts:16:18 - The last overload is declared here. 16 declare function ms(value: ms.StringValue): number; ~~ Found 1 error in app.ts:4
The community declaration is stricter than the one you wrote. It uses a template literal type called StringValue, roughly `${number}${Unit}`, so "two hours" is rejected at compile time instead of turning into undefined at runtime. Having rejected nonsense text, it declares the result as plain number. Reading an @types file is a good way to learn declaration techniques.
To see which declaration file the compiler actually picked for an import, ask it to explain why every file is part of the program:
npx tsc --noEmit --explainFiles | grep -A2 "@types/ms" node_modules/@types/ms/index.d.ts Imported via "ms" from file 'app.ts' with packageId '@types/ms/index.d.ts@2.1.0' File is CommonJS module because 'node_modules/@types/ms/package.json' does not have field "type"
Some practical rules for @types:
- Install them as dev dependencies (
-D), since only the compiler uses them. The exception is a library whose own published.d.tsfiles mention those types; then its users need them too, and they go independencies. - Match the versions.
@types/ms@2.1.xdescribesms@2.1.x. The major and minor numbers of an@typespackage follow the library's; the patch number is the declarations' own. - Remove your own declaration once you install one. If a hand-written
declare module "ms"is still intypes/, the compiler uses it before the installed one, and you keep your weaker types without noticing. - Check whether the library already ships types. Many do:
zod,vitestand every@zudojs/*package include their.d.tsfiles.npm view <package> types exportsshows the fields, and a@typespackage for a library that ships its own is at best useless and at worst out of date. - The
typescompiler option limits global types. The course'stsconfig.jsonhas"types": ["node"]: only@types/nodemay add global names (such asprocess). Packages youimportare found regardless. If a test framework's globals such asdescribeare "not found", this list is usually why.
| The package… | Do this |
|---|---|
ships its own .d.ts (has "types" in package.json or in "exports") | Nothing. Import it. |
has an @types/ package | npm install -D @types/name, matching the library's major version |
| has neither | Write types/name.d.ts with declare module "name" { … }, only for the parts you use. Consider contributing it to DefinitelyTyped. |
| is your own JavaScript | A sibling .d.ts, or JSDoc types plus generated declarations (below) |
Global declarations
Most code arrives through imports, but some names simply exist: process in Node.js, window in a browser, or a constant that a build tool writes into your code. A global declaration tells the compiler about a name that is available everywhere without an import.
Script files declare globals
A .d.ts file with no top-level import or export is a script, and everything it declares is global. Suppose the build pipeline injects the release version as APP_VERSION (bundlers have a define option that replaces a name with a value in the output):
/** Injected by the build: the release being run, such as "2026.9.1". */
declare const APP_VERSION: string;
interface ShopSettings {
vatRate: number;
currency: "NGN";
}
const settings: ShopSettings = { vatRate: 0.075, currency: "NGN" };
try {
console.log(`Version ${APP_VERSION}, VAT ${settings.vatRate * 100}%`);
} catch (error) {
console.log((error as Error).message);
}
npx tsx footer.tsAPP_VERSION is not defined
It type-checks, because the declaration says APP_VERSION exists. It fails when run with tsx, because nothing defined it: only the production build replaces the name. A global declaration is the most dangerous kind of promise, since it is true in some environments and false in others. Keep them few, and make sure every environment that runs the code (tests included) really provides them.
declare global inside a module
Once a file has an import or export, it is a module and its declarations are local. To add globals from a module, wrap them in declare global { … }. The best-known use is typing environment variables. @types/node declares process.env with the interface NodeJS.ProcessEnv, where every variable is string | undefined. Because interfaces with the same name merge (their members are combined), you can add your own variables to it:
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
PAYSTACK_SECRET_KEY: string;
}
}
}
export {};
export {} exports nothing; it is there only to make the file a module, which declare global requires. Now the compiler believes you:
const mode = process.env.NODE_ENV;
const secret: string = process.env.PAYSTACK_SECRET_KEY;
console.log(mode === "production" ? "live keys" : "test keys");
console.log(typeof secret);
try {
console.log(`key starts with ${secret.slice(0, 7)}`);
} catch (error) {
console.log((error as Error).message);
}
npx tsx payments.tstest keys undefined Cannot read properties of undefined (reading 'slice')
Every line compiled, and the secret was undefined, because this terminal never set PAYSTACK_SECRET_KEY. Declaring an environment variable as string is the same kind of promise as as string: it moves the failure from a clear error at startup to a confusing one deep inside a payment. The safer design keeps ProcessEnv as it is (string | undefined), reads the variables once, checks them, and hands the rest of the program a typed object. What the TypeScript compiler does built such a reader, and Runtime validation does it with schemas. Augment ProcessEnv only for the literal types of variables that the platform itself guarantees.
GLOBALS ARE VAR
To declare a global variable that code really assigns (for exampleglobalThis.requestCount = 0 in a test setup file), write var requestCount: number; inside declare global. Only var adds a property to globalThis; let and const do not, so globalThis.requestCount would be an error.Module augmentation: adding to someone else's interface
Sometimes the types exist and are right, but your application adds something to an object that the library's types do not know about. The classic case is an authentication step that attaches the logged-in user to the incoming request. Node's IncomingMessage has no user property, so every later handler would need an assertion. Module augmentation reopens a module's declarations from outside and merges new members into its interfaces:
import { IncomingMessage } from "node:http";
import { Socket } from "node:net";
declare module "node:http" {
interface IncomingMessage {
user?: { id: string; role: "customer" | "admin" };
}
}
export function authenticate(req: IncomingMessage): void {
if (req.headers.authorization === "Bearer ada-token") {
req.user = { id: "u_1", role: "admin" };
}
}
const req = new IncomingMessage(new Socket());
req.headers.authorization = "Bearer ada-token";
authenticate(req);
console.log(req.user?.role);
npx tsx auth.tsadmin
Inside a module file, declare module "node:http" { … } does not declare a new module; it adds to the existing one. The interface IncomingMessage inside merges with Node's, so req.user is known everywhere in the project, typed as optional because unauthenticated requests do not have it. Frameworks use this pattern on purpose: Express users augment Request, and Vitest users augment its Assertion interface to type custom matchers (you will do that in Testing TypeScript).
It works for your own modules too. A shop's cart module knows nothing about coupons; a separate coupons feature adds the field it needs:
export interface Cart {
id: string;
items: { sku: string; kobo: number; quantity: number }[];
}
export function subtotal(cart: Cart): number {
return cart.items.reduce((sum, item) => sum + item.kobo * item.quantity, 0);
}
import { subtotal, type Cart } from "./cart.js";
declare module "./cart.js" {
interface Cart {
coupon?: { code: string; percentOff: number };
}
}
export function applyCoupon(cart: Cart, code: string, percentOff: number): Cart {
return { ...cart, coupon: { code, percentOff } };
}
export function total(cart: Cart): number {
const gross = subtotal(cart);
return cart.coupon ? Math.round(gross * (1 - cart.coupon.percentOff / 100)) : gross;
}
import type { Cart } from "./cart.js";
import { applyCoupon, total } from "./coupons.js";
const cart: Cart = { id: "C-1", items: [{ sku: "RICE-5KG", kobo: 1_250_000, quantity: 2 }] };
const discounted = applyCoupon(cart, "SALLAH10", 10);
console.log(total(cart), total(discounted), discounted.coupon?.code);
npx tsx checkout.ts and of the browser terminal2500000 2250000 SALLAH10
Should you do this with your own code? Usually not: if you own cart.ts, add the field there. Augmentation is for code you cannot edit, or for a plugin system where the core deliberately stays ignorant of its plugins.
What augmentation cannot do
These experiments start from a fresh copy of the cart module, without the coupons file:
export interface Cart {
id: string;
items: { sku: string; kobo: number; quantity: number }[];
}
The module name must resolve to a real module, exactly as in an import. A typo is reported:
declare module "./carts.js" {
interface Cart {
giftWrap?: boolean;
}
}
export {};
npx tsc --noEmit printsgift-wrap.ts:1:16 - error TS2664: Invalid module name in augmentation, module './carts.js' cannot be found.
1 declare module "./carts.js" {
~~~~~~~~~~~~
Found 1 error in gift-wrap.ts:1Only interfaces (and namespaces) merge. A type alias with the same name is a second, conflicting declaration:
declare module "./cart.js" {
type Cart = { giftWrap?: boolean };
}
export {};
npx tsc --noEmit printscart.ts:1:18 - error TS2300: Duplicate identifier 'Cart'.
1 export interface Cart {
~~~~
gift-wrap.ts:2:8 - 'Cart' was also declared here.
2 type Cart = { giftWrap?: boolean };
~~~~
gift-wrap.ts:2:8 - error TS2300: Duplicate identifier 'Cart'.
2 type Cart = { giftWrap?: boolean };
~~~~
cart.ts:1:18 - 'Cart' was also declared here.
1 export interface Cart {
~~~~
Found 2 errors in 2 files.
Errors Files
1 cart.ts:1
1 gift-wrap.ts:2This is one practical reason libraries export interfaces rather than type aliases for their extension points. The worst failure is the one the compiler does not report. An augmentation can declare a brand-new function, and the compiler takes your word that it exists:
import type { Cart } from "./cart.js";
declare module "./cart.js" {
export function giftWrapFee(cart: Cart): number;
}
import * as carts from "./cart.js";
const cart: carts.Cart = { id: "C-2", items: [] };
console.log(typeof carts.giftWrapFee);
try {
console.log(carts.giftWrapFee(cart));
} catch (error) {
console.log((error as Error).message);
}
npx tsx receipt.ts and of the browser terminalundefined carts.giftWrapFee is not a function
Augmentation changes types, never values. Adding a property to an interface is fine when some code really sets it (like authenticate above). Declaring a function that no JavaScript defines is a lie with a delay.
Generating declarations instead of writing them
Hand-written declarations drift away from the code. The robust answer is to let tsc produce them from something it can check.
From TypeScript
If the library is written in TypeScript, turn on declaration and tsc writes a .d.ts for every file, with inferred types spelled out:
export const VAT_RATE = 0.075;
export function addVat(kobo: number) {
return Math.round(kobo * (1 + VAT_RATE));
}
export function splitBill(kobo: number, people: number) {
const share = Math.floor(kobo / people);
return { share, remainder: kobo - share * people };
}
export const currencies = ["NGN", "USD"] as const;
npx tsc --noEmit false --outDir dist --declarationexport declare const VAT_RATE = 0.075;
export declare function addVat(kobo: number): number;
export declare function splitBill(kobo: number, people: number): {
share: number;
remainder: number;
};
export declare const currencies: readonly ["NGN", "USD"];
The compiler wrote the return types it inferred, including the object shape of splitBill. Because this file is produced on every build, it cannot drift. Add "declarationMap": true as well, and editors can jump from a user's "go to definition" straight into your .ts source instead of the .d.ts; Publishing TypeScript packages sets this up for a real package.
From JavaScript with JSDoc types
You do not have to rewrite the naira formatter in TypeScript to get generated declarations. The compiler understands types written in JSDoc comments: @param, @returns, @typedef and @type. Turn on allowJs (read JavaScript files) and checkJs (type-check them), and the JavaScript itself becomes the source of truth:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"noEmit": true,
"allowJs": true,
"checkJs": true
}
}
/**
* @typedef {object} FormatOptions
* @property {boolean} [symbol] Put the ₦ sign in front. Default: true.
* @property {number} [decimals] Digits after the decimal point. Default: 2.
*/
export const CURRENCY = /** @type {const} */ ("NGN");
/**
* Formats an amount of kobo (₦1 = 100 kobo) as naira text.
* @param {number} kobo
* @param {FormatOptions} [options]
* @returns {string}
*/
export function formatNaira(kobo, options = {}) {
const { symbol = true, decimals = 2 } = options;
const text = (kobo / 100).toLocaleString("en-NG", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return symbol ? `₦${text}` : text;
}
/**
* Reads text such as "₦1,250.50" back into kobo, or returns null if it is not an amount.
* @param {string} text
* @returns {number | null}
*/
export function parseNaira(text) {
const cleaned = text.replace(/[₦,\s]/g, "");
if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) return null;
return Math.round(Number(cleaned) * 100);
}
export default formatNaira;
TypeScript code can now import ./naira.js directly and gets full types, with no .d.ts at all:
import { formatNaira, parseNaira } from "./naira.js";
const kobo = parseNaira("₦45,000");
console.log(formatNaira(kobo));
npx tsc --noEmit printsinvoice.ts:4:25 - error TS2345: Argument of type 'number | null' is not assignable to parameter of type 'number'.
Type 'null' is not assignable to type 'number'.
4 console.log(formatNaira(kobo));
~~~~
Found 1 error in invoice.ts:4And users of the library in other projects get a generated declaration file. Build with declaration and emitDeclarationOnly (write only .d.ts files, since the JavaScript already exists):
npx tsc --noEmit false --declaration --emitDeclarationOnly --outDir types/**
* @typedef {object} FormatOptions
* @property {boolean} [symbol] Put the ₦ sign in front. Default: true.
* @property {number} [decimals] Digits after the decimal point. Default: 2.
*/
export type FormatOptions = {
/**
* Put the ₦ sign in front. Default: true.
*/
symbol?: boolean;
/**
* Digits after the decimal point. Default: 2.
*/
decimals?: number;
};
export declare const CURRENCY: "NGN";
/**
* Formats an amount of kobo (₦1 = 100 kobo) as naira text.
* @param {number} kobo
* @param {FormatOptions} [options]
* @returns {string}
*/
export declare function formatNaira(kobo: number, options?: FormatOptions): string;
/**
* Reads text such as "₦1,250.50" back into kobo, or returns null if it is not an amount.
* @param {string} text
* @returns {number | null}
*/
export declare function parseNaira(text: string): number | null;
export default formatNaira;
Compare it with the one you wrote by hand: the same signatures, the same number | null, and the documentation carried along. The difference is that checkJs now also checks the function bodies against those comments, so the declaration and the code can no longer disagree. This is how many older JavaScript libraries add types without a rewrite.
isolatedDeclarations: declarations without the type checker
To write splitBill's return type, tsc had to type-check the function body. For a big monorepo that is slow, and it means one package's declarations wait for the whole check. The isolatedDeclarations option demands that every exported function, constant and class has types written out wherever the compiler would otherwise have to infer them, so that any tool can produce the .d.ts from one file at a time, without a type checker:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"noEmit": true,
"verbatimModuleSyntax": true,
"declaration": true,
"isolatedDeclarations": true
}
}
export function addVat(kobo: number) {
return Math.round(kobo * 1.075);
}
export function splitBill(kobo: number, people: number): { share: number; remainder: number } {
const share = Math.floor(kobo / people);
return { share, remainder: kobo - share * people };
}
export const VAT_RATE = 0.075;
npx tsc --noEmit printsmoney.ts:2:10 - error TS9013: Expression type can't be inferred with --isolatedDeclarations.
2 return Math.round(kobo * 1.075);
~~~~~~~~~~~~~~~~~~~~~~~~
money.ts:1:17 - Add a return type to the function declaration.
1 export function addVat(kobo: number) {
~~~~~~
money.ts:2:10 - Add satisfies and a type assertion to this expression (satisfies T as T) to make the type explicit.
2 return Math.round(kobo * 1.075);
~~~~~~~~~~~~~~~~~~~~~~~~
Found 1 error in money.ts:2Only addVat is reported: its return type must be read from the body. splitBill spells its type out, and VAT_RATE is a literal whose type is obvious from the text alone. You will see why this matters for build speed in Compiler performance.
Testing declarations
A declaration file makes two kinds of claims, and each needs its own test:
- Type claims: "
formatNairarefuses a string", "parseNairamay return null". Test them with// @ts-expect-errorlines that must fail to compile, which you met in What the TypeScript compiler does. If a later edit loosens the declaration, the directive becomes unused andtscreports it. - Behaviour claims: "the JavaScript really accepts what the declaration allows, and returns what it says". Test them by running the JavaScript through the typed import.
import formatNaira, { CURRENCY, parseNaira } from "./vendor/naira-format/index.js";
// Type claims: checked by tsc, never executed.
function typeClaims(): void {
// @ts-expect-error: amounts are kobo numbers, never text
formatNaira("2500");
// @ts-expect-error: the parser reads text, not numbers
parseNaira(1250);
// @ts-expect-error: the result may be null, so it must be checked first
const kobo: number = parseNaira("₦10");
}
// Behaviour claims: run the JavaScript through the declaration.
const checks: [string, unknown, unknown][] = [
["format kobo", formatNaira(250_000), "₦2,500.00"],
["no symbol", formatNaira(250_000, { symbol: false }), "2,500.00"],
["parse text", parseNaira("₦2,500.00"), 250_000],
["parse garbage is null", parseNaira("two thousand"), null],
["currency literal", CURRENCY, "NGN"],
];
for (const [name, actual, expected] of checks) {
console.log(`${actual === expected ? "PASS" : "FAIL"} ${name}`);
}
console.log(`${typeof typeClaims} typeClaims: compiled, never called`);
npx tsx naira.contract.ts and of the browser terminalPASS format kobo PASS no symbol PASS parse text PASS parse garbage is null PASS currency literal function typeClaims: compiled, never called
Why are the type claims inside a function that nobody calls? Because a line under @ts-expect-error is still ordinary code. The compiler checks it, and then it runs like any other line: parseNaira(1250) would throw text.replace is not a function and stop the whole test file. Putting type-only lines where they cannot execute keeps the two kinds of test apart. Testing TypeScript does the same job more neatly with Vitest's expectTypeOf, and runs the behaviour checks as real tests.
In production
- Prefer libraries that ship their own types. Types generated from the library's source are updated in the same release as the code.
- Keep hand-written declarations small and local. Declare only the parts of a package you use, in one
types/folder, with a comment naming the library version they describe. Delete them when the library or@typescatches up. - Never use the shorthand
declare module "x";as a permanent fix, and treat@ts-ignoreon an import as a bug report you have not filed yet. - Check your own
.d.tsfiles. WithskipLibCheck: true, errors in them are invisible. Runtsc --skipLibCheck falsein CI once in a while, or whenevertypes/changes. - Globals and augmentations are project-wide. A
declare globalin one file changes the types in every file, including tests. Keep them in a clearly named folder and review them like code. - Type what is true at runtime. Environment variables, injected constants and request properties are only as real as the code or platform that sets them. When in doubt, keep the honest
| undefinedand check it.
Practice
TRY IT YOURSELF
Declare a slug helper
Your shop uses an untyped CommonJS package slugger whose whole API is one function: slugger(text, { separator = "-", maxLength }) returns a lowercase URL slug, and returns an empty string for text with no letters or digits. Write types/slugger.d.ts so that import slugger from "slugger" is fully typed, and name the one thing about the result that a type cannot express.
Show a solution
declare module "slugger" {
interface SlugOptions {
/** Put between words. Default: "-". */
separator?: string;
/** Cut the slug to at most this many characters. */
maxLength?: number;
}
/** Turns "Jollof Rice (Large)" into "jollof-rice-large". Returns "" when nothing is left. */
function slugger(text: string, options?: SlugOptions): string;
export = slugger;
}
export = matches module.exports = slugger, and a default import picks it up from an ES module. The type says string, but it cannot say "may be empty". Document it in the comment, and check for "" before using a slug as a URL.
TRY IT YOURSELF
Type the logged-in user once
Several handlers write (req as any).tenantId. Replace those assertions with an augmentation of node:http's IncomingMessage that adds an optional tenantId, and write a function requireTenant(req) that returns the tenant id as a string or throws.
Show a solution
import { IncomingMessage } from "node:http";
import { Socket } from "node:net";
declare module "node:http" {
interface IncomingMessage {
tenantId?: string;
}
}
export function requireTenant(req: IncomingMessage): string {
if (!req.tenantId) throw new Error("No tenant on this request");
return req.tenantId;
}
const req = new IncomingMessage(new Socket());
try {
requireTenant(req);
} catch (error) {
console.log((error as Error).message);
}
req.tenantId = "shop-lagos";
console.log(requireTenant(req));
npx tsx tenant.tsNo tenant on this request shop-lagos
The property is optional, because a request that has not passed the tenant middleware does not have it. requireTenant turns "maybe" into "definitely" with a runtime check, so the handlers after it get a plain string without any assertion.
TRY IT YOURSELF
Catch a lying declaration
A teammate "simplified" the formatter's declaration to export declare function parseNaira(text: string): number;, and now the compiler lets callers skip the null check. The type check passes everywhere. Write a behaviour check that fails because of this declaration, and say which layer (types or tests) found it.
Show a solution
export function parseNaira(text) {
const cleaned = text.replace(/[₦,\s]/g, "");
if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) return null;
return Math.round(Number(cleaned) * 100);
}
export declare function parseNaira(text: string): number;
import { parseNaira } from "./vendor/naira-format/index.js";
for (const input of ["₦2,500", "two thousand", "-5"]) {
const result = parseNaira(input);
const ok = typeof result === "number";
console.log(`${ok ? "PASS" : "FAIL"} parseNaira(${JSON.stringify(input)}) -> ${result} (declared: number)`);
}
npx tsx parse.contract.ts and of the browser terminalPASS parseNaira("₦2,500") -> 250000 (declared: number)
FAIL parseNaira("two thousand") -> null (declared: number)
FAIL parseNaira("-5") -> null (declared: number)The compiler cannot find this: it trusts the declaration, so inside parse.contract.ts the variable result is typed number, and typeof result === "number" even looks pointless to a reader. Only running the JavaScript shows the null. That is exactly why declaration files need behaviour tests: the test is the one place where the promise meets the real code. The fix is to put | null back in the declaration, and the compiler then shows every caller that skipped the check.
Recap
- A
.d.tsfile describes JavaScript for the compiler: declarations only, no bodies. The compiler finds it beside the.jsfile, through a package'stypes/exports, or innode_modules/@types. - TS7016 means "this JavaScript has no types". Fix it with a declaration, never with
@ts-ignoreor a permanent shorthanddeclare module "x";. - Write declarations from what the code really does: read it, run it on bad input, and put
| nullor| undefinedwhere the code returns them. declare module "name" { … export = … }in a script.d.tstypes an untyped package;@types/namefrom DefinitelyTyped is the shared version.- Script
.d.tsfiles anddeclare globaladd global names; interfaces merge, which is howProcessEnvand module augmentation work. Augmentation changes types, never values. - Nothing checks a hand-written declaration against its JavaScript. Generate declarations from TypeScript or JSDoc (
allowJs,checkJs,declaration), and test the behaviour through the typed import.skipLibCheckhides errors in your own.d.tsfiles.
Next: Publishing TypeScript packages, where you ship JavaScript and declarations together so that nobody has to write a .d.ts for your library.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.