DEV Community

Cover image for declare global vs declare module: When to Use Which
Gabriel Anhaia
Gabriel Anhaia

Posted on

declare global vs declare module: When to Use Which


You've seen this one. An engineer opens a PR adding an auth
field to Express.Request. The .d.ts file looks right, and the
handler that reads req.auth.userId compiles fine. Twenty minutes
after merge, a different service in the same monorepo starts
failing the type check with Property 'auth' does not exist on
type 'Request'
. Same TypeScript version, same tsconfig shape,
same @types/express. Different result.

The reason is one missing line at the bottom of the .d.ts. An
export {} that turns the file into a module, which, in the
upside-down world of ambient declarations, is exactly the line
that lets declare global reach back out into the global scope
on the other end.

Both declare global and declare module 'pkg' exist for the
same kind of job: extending types that live somewhere you don't
own. They are not interchangeable. They live in different scopes
and fail in opposite directions when you pick the wrong one. This
post is the map.

The shape of the problem

There are exactly two places types you don't own can live. One is
the global scope: browser globals, Node globals, runtime-injected
shapes like Express.Request after middleware mutates it. The
other is a module's exported surface: the .d.ts that ships with
zod, vite/client, or any package on npm.

TypeScript gives you one keyword for each:

  • declare global { ... } extends the global scope.
  • declare module 'pkg' { ... } augments the module named 'pkg'.

The keyword you pick has to match the scope you're extending.
That sounds tautological until you remember that declare module
is overloaded. Depending on whether the string after it matches
an existing module, it either augments that module or declares
a brand-new ambient module
. And declare global only works at
all when the file containing it is itself a module. The rules
collide in ways that feel arbitrary in practice.

declare global: rules and the export {} hack

A file that contains a top-level declare global block must be a
module. If the file is a plain .ts or .d.ts with no imports
and no exports, TypeScript treats it as a script. A script's
top-level declarations are already global, so declare global
inside one is meaningless and the compiler rejects it.

Here's the rule in code form. This file fails to compile:

// types/express.d.ts — script form, broken
declare global {
  namespace Express {
    interface Request {
      auth?: { userId: string };
    }
  }
}
// error: Augmentations for the global scope can only be
// directly nested in external modules or ambient module
// declarations.
Enter fullscreen mode Exit fullscreen mode

The fix is to make the file a module. Add any import or export
and the compiler reclassifies the file. The lightest version is
the empty re-export:

// types/express.d.ts — module form, works
declare global {
  namespace Express {
    interface Request {
      auth?: { userId: string };
    }
  }
}

export {};
Enter fullscreen mode Exit fullscreen mode

That trailing export {} is the load-bearing line the engineer
in the scenario forgot. Semantically it exports nothing, an empty
namespace with no values. What it changes is the file's
classification. TS now sees it as a module, and a module is
allowed to reach into the global scope through a declare global
block.

This is the part that bites: when the file isn't a module, the
compiler does not error in every scenario. If the file is being
included only as a side-effect through tsconfig.json's
include pattern, you sometimes get a different shape of error
("Augmentation… can only be… in external modules"). If the file
is being imported with import './types/express.d.ts' and the
.d.ts happens to declare other top-level things, the rule
flips. Whether the file is "a module" depends on the presence of
any import or export, not on whether you import the file from
elsewhere. The fix in every case is the same: put export {} at
the end.

The tsconfig side has its own gate. The file has to be picked up
by the compilation. Either it's inside include, or it's listed
in files, or typeRoots points at a directory that contains
it, or you pull it in with a /// <reference> triple-slash
directive from somewhere that is compiled. If none of those is
true, your global augmentation is a file the compiler never
reads, and the type error you expected to disappear stays.

declare module 'pkg': augmentation, not replacement

declare module 'something' has two completely different
meanings depending on whether 'something' matches a module that
already exists.

If 'something' doesn't match anything the compiler can resolve,
you've declared a new ambient module:

// types/svg-modules.d.ts
declare module '*.svg' {
  const url: string;
  export default url;
}
Enter fullscreen mode Exit fullscreen mode

That's the pattern for telling TS that imports of *.svg resolve
to a string default export, even though there's no real module
backing them. It's a wildcard ambient module declaration.

If 'something' does match an existing module — a package on
disk that has its own .d.ts — the same syntax becomes a module
augmentation
. It merges your declarations into the existing
module's types:

// types/express.d.ts
import 'express';

declare module 'express' {
  interface Request {
    auth?: { userId: string };
  }
}
Enter fullscreen mode Exit fullscreen mode

Three rules go with this and trip people up.

The file must be a module, same rule as declare global. The
import 'express'; at the top satisfies that. The empty export
{}
would also satisfy it. Either is enough.

The import 'express' is not optional in this case for a
separate reason. TS has to have already resolved the original
'express' types before it can augment them. A bare declare
module 'express'
block in a file that doesn't import it can
silently turn into an ambient-module declaration that replaces
the express types instead of merging into them. The import forces
the original to load first.

Augmentation only adds to interfaces and namespaces. It can't
change a type alias, can't replace a function signature, can't
remove members, and can't reassign existing fields. Interface
declarations merge by name. If Request in @types/express has
body: any, your augmentation can add a new field but can't
redefine body to something narrower:

declare module 'express' {
  interface Request {
    auth?: { userId: string };  // works — new field
    body: { userId: string };   // does not work — redeclares
  }
}
Enter fullscreen mode Exit fullscreen mode

The second line either errors (when the existing type is
incompatible with the new one) or silently leaves the original
in place (when TS treats them as the same field). It does not
override. It augments. Never replaces.

Reach for augmentation when you're adding to a third-party
module's shape. Reach for something else, usually a wrapper
module or a typed re-export, when you need to actually change a
type the package owns.

Three cases you'll meet this month

The rules are abstract; the cases are concrete. Three real
examples, one for each shape.

Case 1: extending Express.Request after auth middleware

This is the case teams ship wrong all the time. Auth middleware
writes req.auth = { userId }, downstream handlers read
req.auth.userId. TS has no idea this happened until you tell
it.

The right shape is both an augmentation (so handlers see the
field on express.Request) and a global namespace extension (so
older patterns that read Express.Request directly also see it):

// types/express.d.ts
import 'express';

declare global {
  namespace Express {
    interface Request {
      auth?: { userId: string };
    }
  }
}

declare module 'express' {
  interface Request {
    auth?: { userId: string };
  }
}

export {};
Enter fullscreen mode Exit fullscreen mode

The import 'express' line resolves the original module and
qualifies the file as a module so declare global works. The
Express namespace extension covers the legacy
@types/express-style consumer that reads Express.Request. The
declare module 'express' block covers modern code that imports
Request from 'express' directly. The export {} is redundant
here. import 'express' already makes the file a module, but the
empty re-export documents the intent and survives a refactor that
drops the import.

Note that the two Request interfaces are different types in
modern @types/express (v5+). The legacy global one lives under
Express.Request; the module-exported one is what import {
Request } from 'express'
resolves to. Duplicating the field is
necessary, not redundant.

Case 2: augmenting Vite's ImportMeta

Vite's import.meta.env is a runtime construct the build tool
populates. The default Vite client.d.ts defines a generic
ImportMetaEnv. You add your own variables by augmenting that
interface:

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

interface ImportMetaEnv {
  readonly VITE_API_BASE: string;
  readonly VITE_FEATURE_FLAG_X: 'on' | 'off';
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
Enter fullscreen mode Exit fullscreen mode

This file is a script — no import or export — and that's
intentional. The triple-slash reference at the top pulls in
vite/client.d.ts. Both ImportMetaEnv and ImportMeta are
already declared globally by Vite's types, and interface merging
inside the global scope is what you want here. No declare global
block is needed because the file itself is global. No declare
module 'vite/client'
either; Vite's types are already loaded by
the reference.

The mistake people make in this file is adding export {} at the
bottom out of habit. That turns the file into a module. The
top-level interface ImportMetaEnv { ... } then declares a
module-local ImportMetaEnv, which doesn't merge with Vite's
global one. You get no error, no merge, and your env variables
type to string | undefined like Vite never knew about them. If
a Vite scaffold gave you a vite-env.d.ts with export {} at
the bottom, that's the bug. Delete the line.

The rule for Vite-style global merging: keep the file a script,
and use the triple-slash reference, not an import.

Case 3: ambient module for a wildcard import

Bun's runtime supports importing files by extension that no
bundler typedefs cover (a .bun script, a .toml config, a
proprietary .bundleformat). The pattern is the wildcard ambient
module:

// types/bun-loaders.d.ts
declare module '*.toml' {
  const data: Record<string, unknown>;
  export default data;
}

declare module '*.bun' {
  const exports: { default: (input: string) => string };
  export = exports;
}
Enter fullscreen mode Exit fullscreen mode

This file is a script (no imports, no exports) and that's
correct: declare module '*.toml' here is ambient module
declaration
, not augmentation. Both meanings of declare module
exist in the same syntax; the difference is whether the module
specifier matches anything that already exists. *.toml doesn't,
so this is a fresh declaration.

If you accidentally make this file a module (an import or
export somewhere), the wildcard ambient declarations now live
inside a module instead of globally. They become invisible to
imports elsewhere in the project, exactly the opposite of what
you wanted. Wildcard ambient modules want script-level scope.

Decision rule, on one line each

  • Adding to a global like Window, NodeJS.Global, or Express (the legacy namespace)? declare global { namespace X { ... } } inside a module file. End the file with export {}.
  • Adding to a third-party package's exported types? declare module 'pkg' { ... } after import 'pkg';. Augmentation only adds to interfaces and namespaces; it doesn't replace.

The next two are easy to mix up because the keyword is the same.
Watch the file's module/script status, since that's what flips
the meaning.

  • Telling TS that *.svg / *.toml / wildcard imports resolve to a shape? declare module '*.ext' { ... } in a script file. No export {}.
  • Adding to a global interface that's already declared globally (like Vite's ImportMetaEnv)? Plain interface X { ... } in a script file, with a triple-slash <reference> to the package whose interface you're merging into.

The bugs that come from mixing them up consume hours.

What to do with this on Monday

If you have a .d.ts file in your repo that uses declare
global
, check the bottom for export {} (or any other
import/export in the file). If it's missing, the augmentation may
be silently ignored in some compilation setups and silently
working in others. Add the line.

If you have a declare module 'pkg' block, check that there's an
import 'pkg'; somewhere in the same file. Without it, you risk
declaring a fresh ambient module that shadows the real types
instead of merging with them. Add the import.

If you have a wildcard declare module '*.something' block, make
sure the file containing it has no import or export. If it
does, the wildcard becomes module-scoped and your wildcard
imports elsewhere stop typing.

And if your tsconfig's include pattern doesn't pick up the
.d.ts file at all, none of the above matters. Add the file to
include, list it in files, or pull it in with a ///
<reference path>
directive from a file that is compiled. The
compiler can only enforce a contract it can see.

The export {} line is small. The bug it prevents is the kind
that makes a green CI lie to you for twenty minutes before a
different service in the monorepo finds the lie for you.


If this was useful

TypeScript in Production
in The TypeScript Library picks up exactly here, with the
ambient declaration patterns library authors and monorepo
maintainers ship across Node, Bun, Deno, and the browser. The
chapters on .d.ts authoring, dual ESM/CJS publishing, JSR
publishing, and tsconfig topology cover the same boundaries
this post crosses. It is one of five books in the collection:

  1. TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
  2. The TypeScript Type System — the deep dive. Generics, mapped and conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
  4. PHP to TypeScript — bridge for PHP 8+ developers. Sync to async, generics, discriminated unions.
  5. TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books 1 and 2 are the core path. If you ship a library or
maintain a monorepo, Book 5 is the one that covers ambient
declarations, library typings, and the tsconfig topology this
post lives inside. Book 5 is for anyone shipping TypeScript at
work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)