Skip to Content
TypeScript Advanced: Types That Hold Up in Production
Advanced
0 likes
Courses
TypeScript Advanced: Types That Hold Up in Production

Ambient Modules and Global Augmentation Without Breaking Everything

Declare modules that are not code, add globals with declare global and augment a module you do not own, and learn why the file must be in the program. After this, you can add a declaration without breaking a file you never touched.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If you already write declare module and declare global and know why one needs export {}, skip to "Augment a module you do not own", then continue.

The previous lesson described one JavaScript file. This one covers the jobs that do not fit in a file of their own: an import that is not code (an image, a stylesheet), a package you have not typed yet, a value that a build tool injects, and an extra property you add to an object that somebody else defined. Those are ambient declarations and augmentations. They are also the easiest place to break a whole project from one small file, so the rules matter more than the syntax.

The project

Everything below lives in one small project with a types folder. The tsconfig lists only src at first. That detail matters in a minute.

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "noEmit": true
  },
  "include": ["src"]
}
import logo from "./logo.svg";
import drawChart from "legacy-chart";

console.log(logo, drawChart);
npx tsc -p .
src/main.ts(1,18): error TS2307: Cannot find module './logo.svg' or its corresponding type declarations.
src/main.ts(2,23): error TS2307: Cannot find module 'legacy-chart' or its corresponding type declarations.

Two imports and two errors. One is a file that is not TypeScript, the other is a package with no types. Both are TS2307: the compiler cannot find a module, or declarations for it.

Declare a module that is not code

An ambient module declaration says "a module with this name exists and has this shape". It goes in a .d.ts file. A wildcard in the name covers every file of that kind.

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

declare module "legacy-chart";

The first block types every .svg import as a default-exported string, which is what most bundlers give you. The second line, with no body, is the shorthand: the package exists and everything imported from it is any. Use it to get unblocked, then replace it with a real declaration when the package matters. A shorthand is a debt, and it hides every mistake in the calls.

Why the file is ignored

Run the compiler again.

npx tsc -p .
src/main.ts(1,18): error TS2307: Cannot find module './logo.svg' or its corresponding type declarations.
src/main.ts(2,23): error TS2307: Cannot find module 'legacy-chart' or its corresponding type declarations.

Same errors. types/modules.d.ts exists and is correct, and the compiler never looked at it, because include lists src only. A .d.ts file is not found by name when it describes a module that is not next to the importer. It has to be part of the program. Add the folder.

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "noEmit": true
  },
  "include": ["src", "types"]
}
npx tsc -p .
echo "exit code $?"
exit code 0

💡 Tip: Keep every hand-written ambient file in one folder, such as types/, and name the folder in include. When a declaration seems to do nothing, the first thing to check is whether the file is in the program: npx tsc --listFiles prints every file that was loaded.

Global augmentation

A build tool or a script tag can create values that exist everywhere: a version string, a feature flag, a property on window. You tell TypeScript about them with declare global. That block is only allowed inside a module, so the file needs an import or export, even an empty one.

declare global {
  interface Window {
    appVersion: string;
  }
  const __BUILD_ID__: string;
}
types/globals.d.ts(1,9): error TS2669: Augmentations for the global scope can only be directly nested in external modules or ambient module declarations.

That is the mistake everybody makes once. Add export {} at the top and the file becomes a module.

export {};

declare global {
  interface Window {
    appVersion: string;
  }
  const __BUILD_ID__: string;
}
import logo from "./logo.svg";
import drawChart from "legacy-chart";

window.appVersion = "1.4.0";
console.log(logo, drawChart, __BUILD_ID__);
window.appVersion = 14;
npx tsc -p .
src/main.ts(6,1): error TS2322: Type 'number' is not assignable to type 'string'.

Window is an interface from the DOM types, and an interface can be declared again; the declarations merge. That is how appVersion appears on window. The value __BUILD_ID__ is a const declared with no value, because the bundler replaces it at build time. Nothing in the compiler creates it, so if the bundler is not configured to define it, you get a ReferenceError at run time that the types could not warn you about.

⚠ Warning: Merging means a second declaration of the same property with a different type is an error, not an override. Two libraries that both add Window.config with different types will fight (TS2717, "Subsequent property declarations must have the same type"), and the error points at one of the two declarations.

Take the last line out so the project is clean again.

import logo from "./logo.svg";
import drawChart from "legacy-chart";

window.appVersion = "1.4.0";
console.log(logo, drawChart, __BUILD_ID__);

Augment a module you do not own

You can add to an interface that lives in another module. The block must name the module exactly as it is imported, and the file must be a module.

export interface Config {
  port: number;
}

export const config: Config = { port: 3000 };
import { config } from "./config.js";

declare module "./config.js" {
  interface Config {
    debug: boolean;
  }
}

console.log(config.port);
npx tsc -p .
src/config.ts(5,14): error TS2741: Property 'debug' is missing in type '{ port: number; }' but required in type 'Config'.

The augmentation made debug a required property of Config, so config.ts, which creates a Config without it, no longer compiles. One file you added broke a file you did not touch. That is the cost of a required property in an augmentation. Make everything you add optional (debug?: boolean), unless you also own every place that builds the type.

Rules that keep it safe

  • One file for globals, one folder for ambient modules, both listed in include.
  • Add properties, never change existing ones. Every augmentation should be optional or an addition that cannot break a producer.
  • Describe values that exist. declare const __BUILD_ID__ promises that something defines it. Put the definition in the build config in the same commit.
  • Do not add methods to built-in prototypes just because TypeScript lets you declare them. The declaration is the easy part, and Array.prototype is shared with every library on the page.

If you skip this, you will keep casting window as any and importing images with // @ts-ignore, and every cast hides an error that has nothing to do with the line it sits on. Which global does your code read right now that the compiler has never heard of?

About

Declare modules that are not code, add globals with declare global and augment a module you do not own, and learn why the file must be in the program. After this, you can add a declaration without breaking a file you never touched.

Duration
10 min
Last update
09/28/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.