EthicsComputerCourses
Join training +91 90532 10052

Module Augmentation & Publishing Types

TypeScript · Lesson 26 of 42 Intermediate 8 min read Updated

In Declaration Files & DefinitelyTyped you learnt where library types come from and how to describe an untyped package. Real projects soon need the next step: adding to types that a library already has. Your login middleware puts a user on every Express request; your design system adds a festive colour to a UI kit's theme; your bundler lets you import .svg files. TypeScript knows none of this until you tell it.

This lesson covers module augmentation (adding to a library's types without editing its files), declarations for non-code imports, the "registry" pattern that modern libraries use, and how to publish your own package so its users get good types.

Augmenting a library's interface

Interfaces with the same name merge (see Interfaces vs Type Aliases). Module augmentation uses this across files: you reopen a module with declare module "name" and add members to its interfaces.

Suppose a UI kit package called kirana-ui exports a ThemeColors interface and a color(name) function that accepts keyof ThemeColors. Your shop wants an extra festive colour for Diwali:

TypeScript
// src/theme.d.ts
import "kirana-ui";

declare module "kirana-ui" {
  interface ThemeColors {
    festive: string;
  }
}
TypeScript
// src/index.ts
import { color } from "kirana-ui";

export const banner: string = color("festive"); // now allowed
export const bad: string = color("accent");     // still an error
Output
src/index.ts(4,34): error TS2345: Argument of type '"accent"' is not assignable to parameter of type 'keyof ThemeColors'.

Three rules make augmentation work:

  • The augmenting file must be a module (it has at least one import or export). The import "kirana-ui"; line does that and makes sure the original module is loaded first.
  • The string in declare module "kirana-ui" must match how you import the package.
  • You can only add to things the module already declares, such as extra members in an existing interface. You cannot replace a declaration or change a property's type.

Many libraries are designed for this and tell you in their docs which interface to augment: a theme interface in UI libraries, a matchers interface in test runners, a Register interface in TanStack Router.

Adding user to Express requests

The most common augmentation in Node.js work is a property that middleware adds to the request object. Without it, TypeScript rejects req.user:

Output
src/app.ts(7,12): error TS2339: Property 'user' does not exist on type 'Request<{}, any, any, ParsedQs, Record<string, any>>'.

The Express types (@types/express) declare their Request interface inside a global namespace called Express precisely so you can extend it. Put this in a .d.ts file inside your src folder:

TypeScript
// src/types/express.d.ts
import type { SessionUser } from "../models.js";

declare global {
  namespace Express {
    interface Request {
      user?: SessionUser;
    }
  }
}

Now every handler sees req.user as SessionUser | undefined. Making it optional is honest: requests that skip the login middleware have no user, so handlers must check if (!req.user) before using it. This is one of the few places where you still write the namespace keyword in modern code, because the library chose that shape.

Environment variables work the same way. In a Vite project, the src/vite-env.d.ts file can list your variables so import.meta.env.VITE_API_URL is typed:

TypeScript
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_RAZORPAY_KEY_ID: string;
}

Remember that this only describes what you expect. Validating the real values at startup is covered in Runtime Validation & Type-Safe APIs.

Declaring CSS, SVG and other imports

Bundlers let you import files that are not JavaScript. TypeScript 7 checks even side-effect imports by default (the noUncheckedSideEffectImports option), so a plain import "./styles.css" fails unless something declares it:

Output
src/app.ts(2,8): error TS2882: Cannot find module or type declarations for side-effect import of './styles.css'.

A wildcard module declaration describes every file that matches a pattern:

TypeScript
// src/types/assets.d.ts
declare module "*.css";

declare module "*.svg" {
  const url: string;
  export default url;
}

Now import "./styles.css" is accepted and import logoUrl from "./logo.svg" gives a string. In a Vite project you rarely write these yourself, because vite/client types already declare CSS, images and other assets. You will write them for custom loaders or older webpack setups.

The registry pattern

Some libraries leave an empty interface for you to fill in, then compute their types from it. This lets a library know about your app's routes, events or feature flags without you passing type arguments everywhere. Here is the idea in one runnable file; in real code the first part lives in the library and the second in your app:

TypeScriptRun it yourself
// ---- library part ----
interface FlagRegistry {}

type FlagName = keyof FlagRegistry;

const flagValues = new Map<string, unknown>();

function setFlag<K extends FlagName>(name: K, value: FlagRegistry[K]): void {
  flagValues.set(name, value);
}

function getFlag<K extends FlagName>(name: K): FlagRegistry[K] {
  return flagValues.get(name) as FlagRegistry[K];
}

// ---- app part: fill in the registry ----
interface FlagRegistry {
  upiAutopay: boolean;
  maxCartItems: number;
  festivalBanner: "diwali" | "eid" | "pongal" | "none";
}

setFlag("upiAutopay", true);
setFlag("maxCartItems", 25);
setFlag("festivalBanner", "diwali");

console.log(getFlag("maxCartItems") + 5);
console.log(getFlag("festivalBanner").toUpperCase());
// setFlag("maxCartItems", "25"); // error: string is not a number
Output
30
DIWALI

Because both FlagRegistry declarations merge, FlagName becomes "upiAutopay" | "maxCartItems" | "festivalBanner", and every call is checked against the right value type. With a published library, the app part would be written as declare module "the-library" { interface FlagRegistry { ... } }.

Publishing your own types

When you publish a TypeScript package (to npm or to your company's private registry), you ship compiled JavaScript plus declaration files (.d.ts) that describe it. Turn on these options:

JSON
{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "isolatedDeclarations": true
  }
}

declaration writes a .d.ts next to each .js file. declarationMap lets "Go to Definition" in your users' editors jump to your .ts source instead of the .d.ts. Then point to the files from package.json, with the types condition listed first inside exports:

JSON
{
  "name": "@sharma-school/fees",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "files": ["dist"]
}

isolatedDeclarations (TypeScript 5.5 and later) asks you to annotate everything you export, so that fast tools can write declaration files without running the full type checker. It reports exports whose types would have to be inferred:

TypeScript
export function fee(classNo: number) {
  return classNo * 1000;
}
export const config = { city: "Pune", fees: [1, 2] };
Output
src/app.ts(2,10): error TS9013: Expression type can't be inferred with --isolatedDeclarations.
src/app.ts(4,45): error TS9017: Only const arrays can be inferred with --isolatedDeclarations.

Adding : number to the function and a type to config fixes both. Explicit export types are also good documentation and keep your public API stable. Before publishing, many teams run the @arethetypeswrong/cli tool, which checks that the types resolve correctly for both Node.js and bundler users.

Common mistakes

  • Forgetting to make the augmentation file a module. Without any import or export, declare module "x" in that file replaces the module's types instead of adding to them, and everything else from the package disappears.
  • Putting the .d.ts file outside include. If tsconfig.json does not include the file, the augmentation silently does nothing.
  • Making middleware properties required. user: SessionUser lies on routes without login. Use user?:.
  • Listing types after default in exports. Conditions are matched in order, so types must come first.
  • Using declare module "*.css" for CSS Modules. Class-name imports need a declaration with an object type, or a tool that generates them.

Key points

  • Module augmentation reopens a library module with declare module "name" and merges new members into its interfaces.
  • The augmenting file must be a module, and you can only add members, not change existing ones.
  • Express request properties are added through declare global { namespace Express { interface Request {...} } }.
  • Wildcard declarations such as declare module "*.svg" type non-code imports; TypeScript 7 checks side-effect imports by default.
  • To publish types, use declaration, declarationMap, an exports map with types first, and consider isolatedDeclarations.

Exercise

Your school app uses a package sms-kit that exports interface Templates { otp: { code: string } } and function send<K extends keyof Templates>(to: string, name: K, data: Templates[K]): void. Write an augmentation file that adds a feeReminder template with { studentName: string; amount: number }, then write a correct call and one call TypeScript should reject.

Show solution
TypeScript
// src/types/sms-kit.d.ts
import "sms-kit";

declare module "sms-kit" {
  interface Templates {
    feeReminder: { studentName: string; amount: number };
  }
}
TypeScript
// src/reminders.ts
import { send } from "sms-kit";

send("9876500000", "feeReminder", { studentName: "Ishaan", amount: 4500 }); // ok
send("9876500000", "feeReminder", { studentName: "Ishaan" });              // error: amount is missing

The interface merges with the library's Templates, so keyof Templates now includes "feeReminder" and its data is checked like any other template.

Quick Quiz

1. What does module augmentation let you do?

2. Why does the augmenting file need an import or export?

3. In TypeScript 7, why can import "./styles.css" be an error?

4. In a package's exports map, where should the types condition go?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call