TypeScript Advanced #6: Modules and .d.ts
The previous five posts were all about how to shape types you wrote yourself. This post is the other side — how to receive and extend types coming from outside.
Why do some modules get automatic types while another library gives you a red squiggle the moment you import it? What do you use to add a new property to a global object? The answers live in declaration files and the declare keyword.
How does a module get its types? #
There are three paths TypeScript uses to learn a module’s types.
- Written directly in
.ts— the simplest. Code in the same project. .d.tsfiles bundled with a package — when a library ships its own declaration files.@types/xxxpackages — community declaration files maintained separately by DefinitelyTyped.
npm install --save-dev @types/lodashMost popular libraries now ship .d.ts themselves. The need for @types/... is shrinking. Still, sometimes you need to write one yourself.
How a package exposes types — package.json
#
The types (or typings) field in package.json is the key.
{
"name": "my-lib",
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}The file types points to is the module’s public type interface. When you import { x } from 'my-lib', TypeScript reads that file to learn the type of x.
For modern ESM packages, putting types inside the exports field is the standard.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}Unless you’re a library author, you don’t need to know the details, but when types aren’t picked up, looking at package.json first is the starting point.
.d.ts files — signatures only, no implementation
#
The extension .d.ts stands for declaration. The file holds only type signatures, not executable code.
declare function greet(name: string): string;
declare const VERSION: string;
declare namespace Utils {
function delay(ms: number): Promise<void>;
}The declare keyword means “this value/function/module exists somewhere — trust just the signature and use it.” It promises the actual implementation is in JavaScript somewhere.
It affects type inference, but no trace remains in the compiled JavaScript output. It’s purely a file that adds type information.
When an external library has no types #
If you import a package that has no @types and no own types, you’ll usually see:
Cannot find module 'some-old-lib' or its corresponding type declarations.Two ways to fix.
1) Quick block — declare the entire module as any
#
Add this one line to a file like src/types/global.d.ts in your project.
declare module 'some-old-lib';Now every export from that module is any. Quick to unblock, but autocomplete for that library is gone. Truly a stopgap.
2) Real types — partial declaration files #
The next step is to pick the functions you use most and write their types yourself.
declare module 'some-old-lib' {
export function compute(input: number): number;
export function format(value: string, options?: { lowercase?: boolean }): string;
}Inside declare module '...', write the signatures of the functions you’ll use when importing. You don’t need to write every API — just what you use is enough. Grow it over time.
This pattern shows up often. When external library types are insufficient in real projects, keeping a partial declaration file in your own project to take control is common practice.
Module augmentation — adding to an existing module #
You can also add types to an existing module. For example, when you want to attach extra fields from your app to next-auth’s session type — extend it inside your project without forking the package.
import 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: 'admin' | 'user';
name?: string | null;
email?: string | null;
};
}
}Starting with import 'next-auth' is the trick. The file must have a module import for declare module to be augmentation. Without an import, it’s interpreted as declaring a new module.
interface Session merges your fields into the original Session interface via declaration merging. The reason this works is interface’s extension capability we covered in Basics #3. Type aliases don’t merge, so interface is almost always the right choice in augmentation.
Extending import.meta.env — a Vite pattern you’ll often hit
#
You also use augmentation when extending Vite’s environment variable types.
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_FEATURE_FLAGS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}/// <reference types="vite/client" /> is a special comment called a triple-slash directive. It pulls in the base types Vite provides. On top, you add your project’s environment variable keys to the interface. With this, autocomplete and type inference for import.meta.env.VITE_API_URL are exact in your code.
Next.js works the same way #
To turn on autocomplete for Next.js’s process.env:
declare namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string;
readonly NEXT_PUBLIC_API_URL: string;
}
}namespace NodeJS is a global namespace inside the Node type definitions. Augment its ProcessEnv interface and the keys of process.env autocomplete.
Adding global types — declare global
#
To add types to a global (e.g., the window object) from inside a module, use the declare global block.
export {}; // needed to make this file a module
declare global {
interface Window {
gtag: (...args: unknown[]) => void;
myAppVersion: string;
}
}Two key points.
- Putting
export {}at the top makes this.d.tsa module file. That’s required fordeclare globalto mean anything. Without that line, the whole file is interpreted as a global script anddeclare globalbecomes redundant. Windowis an interface in lib.dom.d.ts — it’s an interface, so it can be augmented. Calls likewindow.gtag(...)autocomplete in TypeScript.
You use the same pattern when adding types to globalThis, process.env, or your own global objects.
Triple-slash directives — relics of an older style #
You’ll occasionally see comments like /// <reference path="..." />, /// <reference types="..." />, and /// <reference lib="..." /> in older code. Now that ESM has settled in, they’re nearly gone, but they’re still common in one place — when a declaration file pulls in another declaration file.
/// <reference types="vite/client" /> in the vite-env.d.ts we saw above is exactly this. It’s where Vite’s base environment types are pulled in without an import to be augmented.
You almost never write them yourself. When you encounter such a line, just read it as “this declaration file depends on another declaration file.”
tsconfig.json’s typeRoots / types #
Two options decide which declaration files your project automatically pulls in.
{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./src/types"],
"types": ["node", "vite/client"]
}
}typeRoots— folders to auto-find type definitions. The default is./node_modules/@types. Specify when you want to add a folder of your project (likesrc/types).types— among packages insidetypeRoots, only this list is auto-included. If unspecified, every package in typeRoots is auto-included.
Don’t fill types unless you must. Leaving it empty pulls in what’s needed automatically. You typically specify it for special cases like “I want to deliberately leave out React DOM types in this project.”
A common mistake — adding one line to types blocks everything else
#
{
"compilerOptions": {
"types": ["node"] // ⚠️ React, vite/client, etc., all dropped
}
}The moment types is even an empty array, only that list is auto-included. If your intent was “add node and keep the rest,” the right move is to not specify types at all.
Where you actually meet these in practice #
Of the tools covered here, the cases you’ll meet at work are limited.
- External library has no types → partial declaration with
declare module 'pkg' { ... } - Extending next-auth/Vite/Next.js environment types → augmentation pattern
- Adding properties to window/global →
declare global { interface Window { ... } } - Building your own library → the
typesfield inpackage.json+.d.tsbuild output
If you’re an app developer (not a library author), recognizing the pattern in those four cases is practical.
Wrap-up #
What this post covered:
- Sources of module types — written directly / bundled with package /
@types/... .d.ts— signatures only, no implementation. Thedeclarekeyword.- Quickly silencing untyped libraries —
declare module 'pkg'; - Partial declarations — write signatures only for the functions you use
- Module augmentation —
import 'pkg'; declare module 'pkg' { ... } - Global augmentation —
export {}; declare global { ... } - Meaning and pitfalls of
typeRoots/typesintsconfig.json
In the next post (#7 Practical patterns and anti-patterns) — the last in the series — we organize the criteria that separate good types from over-typed ones: any vs unknown vs never, as const and satisfies, and the anti-patterns people often fall into.