Declaration Files & Module Augmentation
Declaration files are how TypeScript understands JavaScript. Module augmentation is how you extend types you don't own. And tsconfig.json is where you decide how strict your project actually is. This is the infrastructure knowledge that separates someone who writes TypeScript from someone who architects TypeScript projects.
.d.ts Files â When and Why
Declaration files (.d.ts) contain only type information â no runtime code. They serve three purposes:
- Describing JavaScript libraries â providing types for code written without TypeScript
- Publishing type definitions â shipping types alongside compiled JS packages
- Declaring ambient types â globals, environment variables, module shapes
// types/analytics.d.ts
declare function trackEvent(name: string, properties?: Record<string, unknown>): void;
declare function trackPageView(path: string): void;
declare const __ANALYTICS_KEY__: string;When TypeScript sees a .d.ts file, it uses the types but doesn't compile it to JavaScript â there's nothing to compile.
Ambient Declarations (declare)
The declare keyword tells TypeScript "this exists at runtime, trust me":
// Declaring a global variable injected by a script tag
declare const gtag: (...args: unknown[]) => void;
// Declaring a global function
declare function requestIdleCallback(
callback: (deadline: IdleDeadline) => void,
options?: { timeout: number }
): number;
// Declaring a class that exists at runtime (e.g., from a third-party SDK)
declare class Stripe {
constructor(key: string);
checkout: {
sessions: {
create(params: StripeSessionParams): Promise<StripeSession>;
};
};
}Module Declarations (declare module)
Typing Untyped Modules
When you import a module that has no types:
// types/untyped-lib.d.ts
declare module "untyped-analytics" {
export function init(key: string): void;
export function track(event: string, data?: object): void;
}Typing Non-JS Imports
Webpack, Vite, and other bundlers let you import assets. TypeScript needs to know their shapes:
// types/assets.d.ts
declare module "*.svg" {
const content: React.FC<React.SVGProps<SVGSVGElement>>;
export default content;
}
declare module "*.css" {
const classes: Record<string, string>;
export default classes;
}
declare module "*.png" {
const src: string;
export default src;
}
declare module "*.graphql" {
import { DocumentNode } from "graphql";
const document: DocumentNode;
export default document;
}Wildcard Module Declarations
declare module "*.module.css" {
const classes: Record<string, string>;
export default classes;
}The * acts as a wildcard â any import matching the pattern gets this type.
Global Augmentation (declare global)
To add properties to the global scope from within a module:
// Must be in a file that has import/export (a module)
export {};
declare global {
interface Window {
__INITIAL_STATE__: AppState;
dataLayer: Record<string, unknown>[];
}
// Adding a global function
function structuredClone<T>(value: T): T;
// Extending built-in types
interface Array<T> {
groupBy<K extends string>(fn: (item: T) => K): Record<K, T[]>;
}
}The export {} is critical â without it, the file is a script (not a module) and declare global has different semantics.
Module Augmentation â Extending Third-Party Types
Extending Express Request
One of the most common real-world augmentation needs:
// types/express.d.ts
import { User } from "../models/user";
declare module "express-serve-static-core" {
interface Request {
user?: User;
requestId: string;
startTime: number;
}
}Critical detail: You must augment the correct module. Express's Request interface lives in express-serve-static-core, not express. Getting this wrong is a common source of "my augmentation doesn't work" bugs.
Extending a React Component Library
// types/chakra.d.ts
import "@chakra-ui/react";
declare module "@chakra-ui/react" {
interface ThemeOverride {
colors: {
brand: {
50: string;
100: string;
// ...
900: string;
};
};
}
}Extending Environment Variables
// types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
API_KEY: string;
PORT?: string;
}
}Now process.env.DATABASE_URL is typed as string (not string | undefined), and process.env.NODE_ENV only accepts the three valid values.
Triple-Slash Directives
Legacy but still necessary in some cases:
/// <reference types="vite/client" />
/// <reference path="./custom-types.d.ts" />
/// <reference lib="dom" />typesâ includes type declarations from a package (like@types/node)pathâ includes another declaration filelibâ includes a built-in lib (likedom,es2022)
Modern projects mostly use tsconfig.json for this, but triple-slash directives remain necessary in .d.ts files that need to reference other type packages.
typeRoots and types in tsconfig
{
"compilerOptions": {
"typeRoots": ["./types", "./node_modules/@types"],
"types": ["node", "jest", "webpack-env"]
}
}typeRootsâ Directories to search for type declarations. Defaults tonode_modules/@types.typesâ If specified, only listed packages fromtypeRootsare included. Without this, all@types/*packages are auto-included.
When to use types: In a monorepo where packages should only see their own type dependencies. Without it, installing @types/jest makes describe and it available everywhere â including production code.
DefinitelyTyped and @types/*
DefinitelyTyped is the community repository of type declarations. When you npm install @types/lodash, you get types that correspond to lodash.
npm install --save-dev @types/react @types/react-dom @types/nodeVersion alignment: @types/react@18.x should match react@18.x. Major version mismatches cause subtle type errors.
Publishing Your Own Types
Two approaches:
// Approach 1: Bundled types (preferred for TypeScript projects)
// package.json
{
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
// Approach 2: Separate @types package (for plain JS libraries)
// Published to @types/your-package on DefinitelyTypedThe exports.types field should always come first in the exports conditions â TypeScript resolves conditions in order.
paths Mapping and Project References
Path Aliases
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}Important: paths is only for TypeScript resolution. Your bundler (Webpack, Vite) needs matching alias configuration.
Project References
For large codebases and monorepos:
// tsconfig.json (root)
{
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/frontend" },
{ "path": "./packages/backend" }
]
}
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
}
}Project references enable:
- Incremental builds â only recompile changed packages
- Dependency boundaries â packages can only import from declared references
- Build ordering â
tsc --buildcompiles in dependency order
tsconfig.json Deep Dive â Key Compiler Options
Strictness Options
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
}
}strict: true enables a bundle of options:
| Flag | What it does |
|---|---|
strictNullChecks | null and undefined are distinct types |
strictFunctionTypes | Contravariant function parameter checking |
strictBindCallApply | Type-safe bind, call, apply |
strictPropertyInitialization | Class properties must be initialized |
noImplicitAny | Error on implicit any |
noImplicitThis | Error on this with implicit any type |
useUnknownInCatchVariables | catch(e) gives unknown, not any |
alwaysStrict | Emit "use strict" |
Beyond strict â Options Seniors Enable
exactOptionalPropertyTypes â Distinguishes between "missing" and "present but undefined":
interface Config {
debug?: boolean;
}
// Without exactOptionalPropertyTypes:
const config: Config = { debug: undefined }; // â
allowed
// With exactOptionalPropertyTypes:
const config: Config = { debug: undefined }; // â Error!
// Must omit the property entirely, or provide a booleannoUncheckedIndexedAccess â Index access returns T | undefined:
const map: Record<string, number> = { a: 1 };
const val = map["b"]; // type: number | undefined (not number)Module Resolution
{
"compilerOptions": {
"moduleResolution": "bundler",
"verbatimModuleSyntax": true
}
}moduleResolution: "bundler" (TS 5.0+) â Resolves modules the way bundlers (Vite, Webpack, esbuild) do. Supports package.json exports field, conditional exports, and doesn't require file extensions. This is the correct setting for most modern web projects.
verbatimModuleSyntax (TS 5.0+) â Requires explicit import type for type-only imports. Replaces the older isolatedModules + importsNotUsedAsValues flags:
import type { User } from "./models"; // â
type-only import (erased)
import { formatUser } from "./utils"; // â
runtime import (kept)
import { type Config, loadConfig } from "./config"; // â
mixedThis ensures your imports are transparent about what's erased at compile time â critical for bundlers that process files individually.
Interview Power Moves
-
"I use
moduleResolution: 'bundler'withverbatimModuleSyntaxâ it aligns TypeScript's module resolution with how modern bundlers actually work, and makes type-only imports explicit." -
"Module augmentation requires targeting the correct module â Express's Request is in
express-serve-static-core, notexpress. I've debugged this enough times to always check the source types first." -
"I enable
noUncheckedIndexedAccessandexactOptionalPropertyTypesbeyondstrictâ they catch real bugs thatstrictalone misses." -
"Project references with
composite: trueare essential for monorepo type checking performance â they enable incremental builds and enforce dependency boundaries between packages." -
"The
typesfield in tsconfig is how you prevent test globals likedescribeanditfrom leaking into production code in a monorepo."