EduFlow

Section 5 · Shipping TypeScript

Declaration Files and Module Augmentation

16 min4 min readCode playground

Lesson at a glance

Describe JavaScript you did not write with declaration files, type globals and environment variables, and extend existing interfaces and modules with declaration merging and module augmentation.

  1. 1declare: it exists, trust me
  2. 2Typing a JavaScript package
  3. 3Declaration merging
  4. 4Module augmentation

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Not everything your TypeScript touches was written in TypeScript. Cartwheel’s checkout loads an analytics snippet with a <script> tag, uses an old internal shipping calculator written in JavaScript, reads environment variables through Vite, and runs on a lib that is a year behind the browsers it ships to. For each of these, you need to tell the compiler about code it cannot see. That is what declaration files and declare are for.

declare: it exists, trust me

declare describes something that exists at runtime but is defined somewhere else. It produces no JavaScript. A file ending in .d.ts contains only such declarations:

types/analytics.d.ts
type AnalyticsEvent = 'checkout_started' | 'order_placed' | 'payment_failed';

declare const cartwheelAnalytics: {
  track(event: AnalyticsEvent, props?: Record<string, string | number>): void;
};

This file has no import or export, so it is a global script: everything in it is visible in every file of the project. Now cartwheelAnalytics.track('order_plaecd') is a compile error, which the snippet’s documentation never managed.

A declaration is an unchecked promise, just like the as casts from earlier sections. If the snippet’s real API differs, TypeScript will happily trust your version. Keep declarations small, write only the parts you use, and put a comment with the library version you checked them against.

Typing a JavaScript package

Before writing any declarations for a package, check the two places types usually come from. Many packages ship their own ("types" in their package.json, or .d.ts files beside the JavaScript). For others, the community maintains @types/ packages on DefinitelyTyped: npm install -D @types/legacy-shipping-calc if one exists.

For an internal package with neither, declare the module yourself:

types/legacy-shipping-calc.d.ts
declare module 'legacy-shipping-calc' {
  export type Quote = { carrier: string; feeCents: number; days: number };
  export function quote(country: string, weightGrams: number): Quote[];
}

The shorthand declare module 'legacy-shipping-calc'; with no body also works, and makes every import any. Treat it as a temporary escape hatch during a migration, not as a solution.

Keep hand-written declarations together in one folder, such as types/, and make sure your tsconfig’s include covers it; a .d.ts file the compiler never sees does nothing, and the resulting “Could not find a declaration file for module” error is confusing the first time. Note too that this file is global, like the analytics one: a declare module 'name' block with a string name is how you describe a package from a script file.

Declaration merging

Some declarations with the same name combine instead of clashing. The one you will use most: interfaces merge.

TypeScript
interface CheckoutConfig { currency: string }
interface CheckoutConfig { giftWrap: boolean }

const config: CheckoutConfig = { currency: 'EGP', giftWrap: true }; // both required

Type aliases never merge; two type CheckoutConfig declarations are a “Duplicate identifier” error. This is the main practical difference between type and interface, and it is why libraries that expect to be extended expose interfaces.

Merging is how you fill gaps in the standard library. The playground in this course uses the ES2022 lib, so Array.prototype.findLast (ES2023) is missing from its types even though every current browser has it. Merging into the global Array<T> interface adds it:

TypeScript
interface Array<T> {
  findLast(predicate: (value: T, index: number, array: T[]) => unknown): T | undefined;
}

In a real project, the better fix is the right lib or target setting. Merging is for the cases where config cannot express what the runtime actually has, such as a polyfill you ship yourself.

It is also how Vite types your environment variables. Its client types declare a global ImportMetaEnv interface, and your project merges into it:

src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_PAYMENTS_KEY: string;
}

Module augmentation

To add to a type that lives inside a module, open the module with declare module from a file that is itself a module:

src/theme.d.ts
import 'cartwheel-ui';

declare module 'cartwheel-ui' {
  interface Theme {
    brand: { primary: string; onPrimary: string };
  }
}

The Theme interface inside cartwheel-ui now has a brand field everywhere it is used. To add to the global scope from a module file, use declare global { interface Window { dataLayer: unknown[] } }.

In the exercise you will declare the analytics global, add findLast to the playground’s lib, and extend a config interface by merging. Last lesson: bringing a whole JavaScript codebase across.

Key takeaways

  • A .d.ts file contains only types; declare describes something that exists at runtime but is defined elsewhere.
  • Check for built-in types or an @types/ package before writing declarations, and write only the parts of a library you use.
  • Interfaces with the same name in the same scope merge into one; type aliases never merge, which is why extensible library types are interfaces.
  • declare module 'pkg' { … } in a file that has an import or export augments that module; in a file without one it declares a whole new ambient module.
  • declare global { … } inside a module adds to the global scope, for example a property on Window or a field on ImportMetaEnv.

Further reading

Check your understanding

4 questions

Question 1 of 4

You add import { quote } from 'legacy-shipping-calc', a JavaScript package with no types. What should you try first?

Question 2 of 4

What is keyof CheckoutConfig after these two declarations in the same scope?

TypeScript
interface CheckoutConfig { currency: string }
interface CheckoutConfig { giftWrap: boolean }
Question 3 of 4

A file vite-env.d.ts contains only interface ImportMetaEnv { readonly VITE_API_URL: string } (plus Vite’s reference directive). What does it do?

Question 4 of 4

types/ui.d.ts contains only declare module 'cartwheel-ui' { interface Theme { brand: string } }. Afterwards, every import from cartwheel-ui is missing its other exports. Why?

Practice

Code playground

Declare what the compiler can't see

The app code below already works at runtime, but it does not type-check. Without editing the app code or the tests, add declarations in the marked spot:

  1. Declare the global cartwheelAnalytics from the vendor snippet: a track(event, props?) method where event is an AnalyticsEvent and props maps strings to strings or numbers, plus a read-only sent list of strings.
  2. Add findLast to arrays (it exists in the browser but not in the playground’s ES2022 lib), returning the element type or undefined.
  3. Extend CheckoutConfig with a required giftWrap: boolean, without editing the original interface.
// ---- Vendor snippet (do not edit) ----
// In the real app this arrives in a <script> tag; here we simulate it.
const vendorQueue: string[] = [];
(globalThis as Record<string, unknown>).cartwheelAnalytics = {
  sent: vendorQueue,
  track(event: string, props: Record<string, string | number> = {}) {
    vendorQueue.push(`${event} ${JSON.stringify(props)}`);
  },
};
// ---------------------------------------

type AnalyticsEvent = 'checkout_started' | 'order_placed' | 'payment_failed';

// "Library" config interface (pretend it lives in a package you cannot edit).
interface CheckoutConfig {
  currency: string;
}

// Your declarations go here.

type Order = { id: number; status: 'processing' | 'shipped' | 'delivered'; totalCents: number };

const orders: Order[] = [
  { id: 1041, status: 'shipped', totalCents: 4500 },
  { id: 1042, status: 'shipped', totalCents: 18900 },
  { id: 1043, status: 'processing', totalCents: 1200 },
];

function latestShipped(list: Order[]): Order | undefined {
  return list.findLast((o) => o.status === 'shipped');
}

function placeOrder(order: Order): void {
  cartwheelAnalytics.track('order_placed', { orderId: order.id, totalCents: order.totalCents });
}

const config: CheckoutConfig = { currency: 'EGP', giftWrap: true };

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<keyof CheckoutConfig, 'currency' | 'giftWrap'>>;
type T2 = Expect<Equal<ReturnType<typeof latestShipped>, Order | undefined>>;

function typeTests() {
  // @ts-expect-error: not a known analytics event
  cartwheelAnalytics.track('ordr_placed');
  // @ts-expect-error: property values must be strings or numbers
  cartwheelAnalytics.track('checkout_started', { express: true });
}

placeOrder(latestShipped(orders)!);
console.log(latestShipped(orders)?.id, cartwheelAnalytics.sent, config);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including the type tests
  • latestShipped returns the last shipped order
  • placeOrder sends an order_placed event