.d.ts Files — Ambient Declarations, Asset Imports, Module Typing#
What it is#
A .d.ts (declaration) file is a types-only TypeScript file — it contains type signatures but no runtime code. The compiler reads .d.ts files to learn about external code (JavaScript libraries, build-tool asset imports, global variables injected by the runtime) without producing any JavaScript output. They are TypeScript’s only mechanism for telling the type-checker “this thing exists at runtime; here is what it looks like.” Every npm package that ships types delivers them as .d.ts files; every Vite project relies on a handful of .d.ts files to type CSS, SVG, and image imports.
When you need a .d.ts file#
You write a .d.ts file in five situations:
- A JavaScript library without bundled types — older
npmpackages and internal one-off utilities. - Asset imports — your bundler turns
import logo from './logo.svg'into a string, but TypeScript needs to be told. - Ambient globals — runtime injects
window.__INITIAL_STATE__or a build step definesprocess.env.API_URL. - Module augmentation — extending an existing package’s types (e.g. adding a property to Express’s
Request). - Custom JSX intrinsics or custom elements — telling TS that
<my-button>is a valid tag.
You do not need a .d.ts file for code you author yourself in .ts — the compiler infers types straight from your source.
ls src/types/
Output:
assets.d.ts
env.d.ts
express.d.ts
globals.d.ts
Anatomy of a declaration file#
A .d.ts file looks like a TypeScript file with the bodies stripped out. The declare keyword tells the compiler “trust me, this exists at runtime.”
// src/types/cache.d.ts
declare function memoize<T extends (...args: any[]) => any>(fn: T): T;
declare class Cache<K, V> {
constructor(maxSize?: number);
get(key: K): V | undefined;
set(key: K, value: V): void;
clear(): void;
}
declare const VERSION: string;
interface CacheStats {
hits: number;
misses: number;
}
export { memoize, Cache, VERSION, CacheStats };
Output: (none — exits 0 on success)
The declare keyword is implicit for interface and type aliases — they are inherently type-only. It’s required for function, class, const, let, var, enum, and namespace to make clear that no runtime code is being emitted.
declare module ‘foo’#
When you import an untyped JavaScript package, TypeScript fails with TS7016: “Could not find a declaration file for module ‘foo’.” Fix it by declaring the module’s shape ambiently.
// src/types/legacy-lib.d.ts
declare module 'legacy-lib' {
export function init(config: { apiKey: string; timeout?: number }): void;
export function fetch(path: string): Promise<unknown>;
export const VERSION: string;
interface User {
id: string;
name: string;
}
export type { User };
}
// src/app.ts
import { init, fetch, type User } from 'legacy-lib';
init({ apiKey: process.env.API_KEY!, timeout: 5000 });
Output: (none — exits 0 on success)
If you just need anything to silence the error, use the catch-all form. It types everything from the module as any — a stopgap, not a fix.
// src/types/quick-shim.d.ts
declare module 'untyped-lib';
For a wildcard match (every subpath under a namespace), use *:
declare module 'untyped-pkg/*' {
const value: any;
export = value;
}
Asset imports — typing the bundler#
Bundlers like Vite, Webpack, and esbuild let you import non-JavaScript files: SVG as URL, CSS Modules, raw text, JSON, images. The runtime works, but TypeScript has no idea what those imports return. You teach it with one-line module declarations.
// src/types/assets.d.ts
// Treat every .svg import as a string URL
declare module '*.svg' {
const url: string;
export default url;
}
// Vite's ?url suffix → string
declare module '*.svg?url' {
const url: string;
export default url;
}
// Vite's ?raw suffix → raw file contents as a string
declare module '*.svg?raw' {
const contents: string;
export default contents;
}
// CSS Modules
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}
// JSON
declare module '*.json' {
const value: unknown;
export default value;
}
// Images
declare module '*.png' {
const url: string;
export default url;
}
declare module '*.jpg' {
const url: string;
export default url;
}
Output: (none — exits 0 on success)
The pattern: each Vite/Webpack import suffix gets its own declare module '*.ext' block. The exported shape mirrors what the bundler injects at runtime.
[!TIP] Vite already ships these declarations in
vite/client. Add/// <reference types="vite/client" />(or"types": ["vite/client"]in tsconfig) and you get the full set without authoring them yourself.
The home-hero.svg?raw pattern#
This project’s home page uses Vite’s ?raw import to load an SVG as a literal string, then inlines it into the HTML for styling. The pattern:
// src/pages/index.astro (frontmatter)
import heroSvg from '../assets/home-hero.svg?raw';
// In the template: <div set:html={heroSvg} />
For TypeScript to accept this import without complaint, the project needs the matching declaration:
// src/types/assets.d.ts
declare module '*.svg?raw' {
const contents: string;
export default contents;
}
Without it, tsc --noEmit (run by Astro’s astro check) errors with:
src/pages/index.astro:8:23 - error TS2307: Cannot find module '../assets/home-hero.svg?raw' or its corresponding type declarations.
After the declaration is in place, the import is typed as string and the type-checker accepts the use of set:html={heroSvg} in the template.
astro check
Output:
Result (1 file):
- 0 errors
- 0 warnings
- 0 hints
declare global#
Some code reaches for genuinely global symbols — window.__INITIAL_STATE__, process.env, custom DOM elements. The declare global block lets you extend the global namespace from inside a module file.
// src/types/globals.d.ts
export {}; // makes this file a module
declare global {
interface Window {
__INITIAL_STATE__: { user: { id: string; name: string } | null };
gtag: (event: string, action: string, params?: object) => void;
}
namespace NodeJS {
interface ProcessEnv {
readonly API_URL: string;
readonly NODE_ENV: 'development' | 'production' | 'test';
readonly DATABASE_URL: string;
}
}
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_FEATURE_FLAGS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
}
// src/app.ts
const initialUser = window.__INITIAL_STATE__?.user; // typed!
const apiUrl = process.env.API_URL; // typed!
const viteApi = import.meta.env.VITE_API_URL; // typed!
Output: (none — exits 0 on success)
The export {}; at the top is critical. Without it, the file is treated as a script (not a module) and the declare global block becomes ambient by default — which still works but mixes file kinds in ways that bite later.
[!WARNING]
declare globalonly applies if the file is reachable from your tsconfiginclude. A.d.tsfile insrc/is usually picked up automatically; one intypes/outsidesrc/needs to be added toincludeortypeRoots.
Ambient vs. module declaration files#
.d.ts files come in two flavours, and the distinction governs how the rest of your code sees them.
| Style | Has any import/export? | Effect |
|---|---|---|
| Ambient (script-style) | No | Declarations are added to the global scope. declare module 'foo' blocks declare external modules. |
| Module (module-style) | Yes — at least one import or export | The file is a module. To affect globals, you must wrap in declare global { … }. |
// Ambient style — adds Cache and memoize to global scope
declare class Cache<K, V> { /* ... */ }
declare function memoize<T>(fn: T): T;
// Module style — exports Cache and memoize as named imports
export declare class Cache<K, V> { /* ... */ }
export declare function memoize<T>(fn: T): T;
The choice depends on intent. Ambient is right for build-time injection (globals, asset modules); module style is right when consumers should import { Cache } from 'my-types'.
Triple-slash directives#
Triple-slash directives are one-line instructions at the top of a .d.ts (or .ts) file. They predate ES modules and are mostly used to compose declaration files.
/// <reference types="node" />
/// <reference path="./shared.d.ts" />
/// <reference lib="dom" />
| Directive | Purpose |
|---|---|
types="x" | Pull in @types/x package types as if added to types in tsconfig |
path="./y.d.ts" | Inline another declaration file’s contents |
lib="x" | Add a built-in lib (dom, es2022, webworker) to this file only |
The most common use is at the top of a Vite project’s env.d.ts:
/// <reference types="vite/client" />
This single line imports all of Vite’s bundler asset typings (*.svg, *.css, *.svg?raw, *.png, …) so you don’t have to author them.
tsconfig: types, typeRoots, include#
The compiler decides which .d.ts files to load via three settings.
{
"compilerOptions": {
"types": ["node", "vite/client"],
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src/**/*"]
}
| Setting | Effect |
|---|---|
types | Whitelist of packages from typeRoots to include automatically. If unset, every @types/* package is included. If set, only the listed ones are. |
typeRoots | Directories to scan for declaration packages. Defaults to ./node_modules/@types. |
include / files | Glob patterns picked up as part of the program — all .d.ts files in src/ are automatically loaded. |
The most frequent mistake: setting types: ["node"] and forgetting to add "vite/client", breaking every asset import. The two are independent — types is an exhaustive list once set.
[!TIP] For most projects, leave
typesunset and rely on auto-discovery. Only set it when you have conflicting@typespackages or want to lock down the type surface explicitly.
declare module augmentation#
You can extend an existing module’s declared types from your own .d.ts. The compiler merges your declarations into the original. This is declaration merging at the module level — see the dedicated declaration merging cheat sheet for the full mechanics.
// src/types/express.d.ts
import 'express'; // import the original module's declarations
declare module 'express' {
interface Request {
user?: { id: string; email: string };
requestId: string;
}
}
// src/middleware.ts
import type { Request, Response, NextFunction } from 'express';
export function authenticate(req: Request, res: Response, next: NextFunction): void {
req.user = { id: 'u_1', email: 'alice@example.com' };
req.requestId = crypto.randomUUID();
next();
}
Output: (none — exits 0 on success)
The import 'express' at the top of the .d.ts is essential. It makes the file a module (so the declare module block is treated as an augmentation, not a re-declaration).
JSX intrinsic elements and custom elements#
If you use a custom web component (e.g. <my-button>), TypeScript’s JSX checker rejects it with TS2339: “Property ‘my-button’ does not exist on type ‘JSX.IntrinsicElements’.” Fix it by augmenting JSX.IntrinsicElements.
// src/types/custom-elements.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
declare global {
namespace JSX {
interface IntrinsicElements {
'my-button': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
};
'tool-tip': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
for: string;
};
}
}
}
// src/App.tsx
function App() {
return (
<my-button variant="primary" size="md">
Click me
</my-button>
);
}
Output: (none — exits 0 on success)
Common pitfalls#
- Missing
export {};in adeclare globalfile — the file silently becomes a script, polluting the global scope. Always add it when the file containsdeclare globaland nothing else. .d.tsfiles not ininclude— declarations intypes/outsidesrc/are never loaded. Either add the directory toincludeor move it undersrc/.declare module '*.svg'collides withvite/client— declaring the same module twice causes “Duplicate identifier ‘default’” errors. Remove your custom block if you’ve added/// <reference types="vite/client" />.declare module 'foo'doesn’t merge with the real package — works only when there’s a missing-types error. If the package ships its own.d.ts, you need module augmentation (import 'foo'; declare module 'foo' { … }), not a fresh declaration.- Mixing
export =and ES exports —export = Xdeclares a single CJS-style export. It’s incompatible withexport { X }in the same file. Pick one. declare classwith method bodies —.d.tsfiles cannot contain function bodies. Use signatures only:foo(): string;notfoo() { return 'x'; }.process.env.MY_VARtyped asstring | undefinedeven after augmentingProcessEnv— the augmentation only flows when the file is reachable. Confirm withtsc --listFiles | grep globals.d.ts.- Triple-slash directive after the first
import— directives must be at the top of the file, before any other statement. Move them above all imports. @types/fooand bundled types conflict — newer versions offooship their own types, making@types/fooredundant. Uninstall@types/footo avoid “Subsequent property declarations must have the same type” errors.export defaultin a.d.tsfor a CJS module — CJS modules useexport =. Mixing forms breaks consumer imports underverbatimModuleSyntax.
Real-world recipes#
Typing the Astro home-hero.svg?raw import#
This project’s src/pages/index.astro does:
import heroSvg from '../assets/home-hero.svg?raw';
The matching declaration lives in src/env.d.ts (or any .d.ts reachable from include):
/// <reference types="astro/client" />
declare module '*.svg?raw' {
const contents: string;
export default contents;
}
astro check
Output:
Result (12 files):
- 0 errors
- 0 warnings
- 0 hints
astro/client already covers *.svg, *.png, *.jpg, *.css?inline, and a few more. The ?raw suffix is a Vite extension, so you supplement it with the explicit declaration above.
Typing window.INITIAL_STATE for SSR hydration#
A server-rendered page injects initial state into a <script> tag; the client picks it up from window. Type it so the client code doesn’t read any.
<!-- server-rendered HTML -->
<script>window.__INITIAL_STATE__ = { user: { id: 'u_1', name: 'Alice Dev' } };</script>
// src/types/window.d.ts
export {};
declare global {
interface Window {
__INITIAL_STATE__: {
user: { id: string; name: string } | null;
};
}
}
// src/hydrate.ts
const state = window.__INITIAL_STATE__;
if (state.user) {
console.log(`Welcome back, ${state.user.name}`);
}
Output: (none — exits 0 on success)
Typing an untyped npm package#
You depend on legacy-color which doesn’t ship types. The error: Could not find a declaration file for module 'legacy-color'. Author a minimal shim.
// src/types/legacy-color.d.ts
declare module 'legacy-color' {
export type RGB = [number, number, number];
export function parse(input: string): RGB;
export function format(rgb: RGB): string;
export function darken(rgb: RGB, amount: number): RGB;
export function lighten(rgb: RGB, amount: number): RGB;
const DEFAULT_PALETTE: Record<string, RGB>;
export default DEFAULT_PALETTE;
}
import palette, { parse, darken, type RGB } from 'legacy-color';
const accent: RGB = parse('#8a5cff');
const accentDark = darken(accent, 0.2);
console.log(palette.brand);
Output: (none — exits 0 on success)
Typing process.env#
Without help, process.env.API_URL is string | undefined. Augment NodeJS.ProcessEnv to make required variables string.
// src/types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: 'development' | 'production' | 'test';
readonly API_URL: string;
readonly DATABASE_URL: string;
readonly STRIPE_KEY?: string; // optional ones keep ?
}
}
}
export {};
const dbUrl = process.env.DATABASE_URL; // typed: string (not undefined)
const stripe = process.env.STRIPE_KEY; // typed: string | undefined
Output: (none — exits 0 on success)
[!WARNING] This is type-only — TS doesn’t enforce that the variable is actually set at runtime. Pair the declaration with a runtime check (Zod, envalid) at startup.
Typing Vite’s import.meta.env#
Vite injects import.meta.env with build-time variables. The default type is Record<string, string> — augment it for known variables.
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_SENTRY_DSN: string;
readonly VITE_FEATURE_NEW_NAV: string; // env vars are always strings
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
const apiUrl = import.meta.env.VITE_API_URL;
const flag = import.meta.env.VITE_FEATURE_NEW_NAV === 'true';
Output: (none — exits 0 on success)
Shimming a CSS-in-JS library’s theme#
styled-components has its own DefaultTheme interface, intentionally empty so consumers can augment it.
// src/types/styled.d.ts
import 'styled-components';
import type { theme } from '../theme';
declare module 'styled-components' {
export interface DefaultTheme {
colors: typeof theme.colors;
spacing: typeof theme.spacing;
fonts: typeof theme.fonts;
}
}
// src/components/Button.tsx
import styled from 'styled-components';
export const Button = styled.button`
color: ${(props) => props.theme.colors.accent};
padding: ${(props) => props.theme.spacing.md};
`;
Output: (none — exits 0 on success)
Authoring a .d.ts alongside a published JS library#
If you ship a JavaScript library on npm, generate a .d.ts from your .ts sources with tsc --declaration.
// tsconfig.build.json
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
tsc -p tsconfig.build.json
ls dist/
Output:
index.d.ts
index.d.ts.map
index.js
index.js.map
util.d.ts
util.d.ts.map
util.js
util.js.map
Wire it up via package.json so consumers pick up the types automatically:
{
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
Generating types from a JSON Schema#
Some APIs publish a JSON Schema or OpenAPI document. Convert it to a .d.ts so your client code is typed end-to-end.
npx openapi-typescript https://api.example.com/openapi.json -o src/types/api.d.ts
Output:
🚀 https://api.example.com/openapi.json → src/types/api.d.ts (412ms)
// src/api-client.ts
import type { paths } from './types/api';
type GetUserResponse = paths['/users/{id}']['get']['responses']['200']['content']['application/json'];
export async function getUser(id: string): Promise<GetUserResponse> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
Output: (none — exits 0 on success)