cheat sheet
.d.ts Files
Authoring .d.ts files — ambient declarations, declare module 'foo', asset typing (*.svg?raw), declare global, triple-slash refs, and the home-hero.svg?raw pattern used in this project.
.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.
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.
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.
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)
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)