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:

  1. A JavaScript library without bundled types — older npm packages and internal one-off utilities.
  2. Asset imports — your bundler turns import logo from './logo.svg' into a string, but TypeScript needs to be told.
  3. Ambient globals — runtime injects window.__INITIAL_STATE__ or a build step defines process.env.API_URL.
  4. Module augmentation — extending an existing package's types (e.g. adding a property to Express's Request).
  5. 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.

bash
ls src/types/

Output:

text
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."

typescript
// 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.

typescript
// 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 };
}
typescript
// 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.

typescript
// src/types/quick-shim.d.ts
declare module 'untyped-lib';

For a wildcard match (every subpath under a namespace), use *:

typescript
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.

typescript
// 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:

typescript
// 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:

typescript
// 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:

text
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.

bash
astro check

Output:

text
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.

typescript
// 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;
  }
}
typescript
// 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 global only applies if the file is reachable from your tsconfig include. A .d.ts file in src/ is usually picked up automatically; one in types/ outside src/ needs to be added to include or typeRoots.

Ambient vs. module declaration files

.d.ts files come in two flavours, and the distinction governs how the rest of your code sees them.

StyleHas any import/export?Effect
Ambient (script-style)NoDeclarations are added to the global scope. declare module 'foo' blocks declare external modules.
Module (module-style)Yes — at least one import or exportThe file is a module. To affect globals, you must wrap in declare global { … }.
typescript
// Ambient style — adds Cache and memoize to global scope
declare class Cache<K, V> { /* ... */ }
declare function memoize<T>(fn: T): T;
typescript
// 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.

typescript
/// <reference types="node" />
/// <reference path="./shared.d.ts" />
/// <reference lib="dom" />
DirectivePurpose
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:

typescript
/// <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.

json
{
  "compilerOptions": {
    "types": ["node", "vite/client"],
    "typeRoots": ["./node_modules/@types", "./src/types"]
  },
  "include": ["src/**/*"]
}
SettingEffect
typesWhitelist of packages from typeRoots to include automatically. If unset, every @types/* package is included. If set, only the listed ones are.
typeRootsDirectories to scan for declaration packages. Defaults to ./node_modules/@types.
include / filesGlob 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 types unset and rely on auto-discovery. Only set it when you have conflicting @types packages 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.

typescript
// 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;
  }
}
typescript
// 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.

typescript
// 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;
      };
    }
  }
}
tsx
// src/App.tsx
function App() {
  return (
    <my-button variant="primary" size="md">
      Click me
    </my-button>
  );
}

Output: (none — exits 0 on success)

Common pitfalls

  1. Missing export {}; in a declare global file — the file silently becomes a script, polluting the global scope. Always add it when the file contains declare global and nothing else.
  2. .d.ts files not in include — declarations in types/ outside src/ are never loaded. Either add the directory to include or move it under src/.
  3. declare module '*.svg' collides with vite/client — declaring the same module twice causes "Duplicate identifier 'default'" errors. Remove your custom block if you've added /// <reference types="vite/client" />.
  4. 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.
  5. Mixing export = and ES exportsexport = X declares a single CJS-style export. It's incompatible with export { X } in the same file. Pick one.
  6. declare class with method bodies.d.ts files cannot contain function bodies. Use signatures only: foo(): string; not foo() { return 'x'; }.
  7. process.env.MY_VAR typed as string | undefined even after augmenting ProcessEnv — the augmentation only flows when the file is reachable. Confirm with tsc --listFiles | grep globals.d.ts.
  8. 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.
  9. @types/foo and bundled types conflict — newer versions of foo ship their own types, making @types/foo redundant. Uninstall @types/foo to avoid "Subsequent property declarations must have the same type" errors.
  10. export default in a .d.ts for a CJS module — CJS modules use export = . Mixing forms breaks consumer imports under verbatimModuleSyntax.

Real-world recipes

Typing the Astro home-hero.svg?raw import

This project's src/pages/index.astro does:

typescript
import heroSvg from '../assets/home-hero.svg?raw';

The matching declaration lives in src/env.d.ts (or any .d.ts reachable from include):

typescript
/// <reference types="astro/client" />

declare module '*.svg?raw' {
  const contents: string;
  export default contents;
}
bash
astro check

Output:

text
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.

html
<!-- server-rendered HTML -->
<script>window.__INITIAL_STATE__ = { user: { id: 'u_1', name: 'Alice Dev' } };</script>
typescript
// src/types/window.d.ts
export {};

declare global {
  interface Window {
    __INITIAL_STATE__: {
      user: { id: string; name: string } | null;
    };
  }
}
typescript
// 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.

typescript
// 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;
}
typescript
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.

typescript
// 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 {};
typescript
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.

typescript
// 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;
}
typescript
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.

typescript
// 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;
  }
}
typescript
// 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.

json
// tsconfig.build.json
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": false,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
bash
tsc -p tsconfig.build.json
ls dist/

Output:

text
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:

json
{
  "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.

bash
npx openapi-typescript https://api.example.com/openapi.json -o src/types/api.d.ts

Output:

text
🚀 https://api.example.com/openapi.json → src/types/api.d.ts (412ms)
typescript
// 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)