Skip to content
Chapter 6Lesson 4

Augmenting third-party modules

Extending a package's published types with TypeScript's declare module, and knowing when the library offers a better path.

Your project has branded IDs: UserId is string & { readonly __brand: 'UserId' }, so a raw string can never stand in for a UserId, the substitution that ends with one tenant reading another tenant’s data. Then you wire up Better Auth, which publishes its Session.user.id as a plain string. Every Server Action that reads the user ID off the session and hands it to a function expecting UserId now has to either cast it or weaken the function to accept a bare string, and both undo the branding from chapter 5.

This lesson teaches declare module, the TypeScript mechanism for extending a third-party package’s published interfaces from a .d.ts file your project owns. The syntax is a few lines; the harder part is knowing when to use it, and whether the library already offers its own extension contract instead.

Both fixes below satisfy the type checker, but neither holds up over time.

export const archiveInvoice = async (formData: FormData) => {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return err('unauthorized', 'Sign in to continue.');
const ownerId = session.user.id as UserId;
return invoiceQueries.archive(ownerId, formData.get('invoiceId'));
};

The cast fixes the type here and enforces nothing elsewhere, allowing exactly the substitution the brand was meant to prevent. A teammate copies the handler, forgets the cast, and the type checker stays quiet.

The fix is to make session.user.id be a UserId at the type level, right at the seam where the library publishes its session shape. Then every caller inherits the correct type for free, the cast disappears, and the brand stays intact. That is what declare module exists for.

types/example.d.ts
import type { UserId } from '@/lib/branded';
declare module 'some-library' {
interface User {
id: UserId;
}
}

A declare module 'some-library' { ... } block merges its contents into the types 'some-library' already publishes. This is declaration merging , and applying it to a third-party package is module augmentation . The merge is build-time only: the .d.ts ships no runtime code, so some-library runs exactly as before and only its exported types change.

The interface keyword is load-bearing. A type alias has nothing to merge into, so a library publishes its extensible surface as interface. Chapter 4’s rule still holds, type is the default in app code; interface is the form a library reaches for at its extension points.

The import type { UserId } line is also load-bearing. declare module 'x' { ... } only augments when the file is a module, which a top-level import or export makes it. Drop the import and the file becomes a global ambient declaration , so declare module 'next-intl' now declares a non-existent global module of that name: the augmentation silently never fires, with no editor warning.

Augmentations live in a top-level types/ directory, one file per package, named to match the package’s import specifier.

  • Directorytypes/
    • next-intl.d.ts
    • drizzle.d.ts
  • Directorysrc/
    • Directoryapp/
    • Directorylib/
  • tsconfig.json

The package name as the filename, types/next-intl.d.ts rather than a catch-all types/global.d.ts, makes each augmentation easy to find from a call site, to review on a library upgrade, and to delete once the upstream library adds the field natively.

Keep the directory outside src/: a types/ folder reads as type-only seams to third-party packages, while the same files beside src/lib/ blur into application code.

tsconfig.json’s include array matches **/*.d.ts, so it picks these files up automatically and the augmentation takes effect project-wide on save. Your editor’s TypeScript language server only sees a newly added file after you restart the TS server, the TypeScript: Restart TS Server command in VS Code.

Module augmentation has a cost: the .d.ts file must be reviewed on every library upgrade, and it breaks silently when the library reorganizes the interface it merges into. Three questions justify paying it.

  1. Does the third-party type appear in five or more places without the augmentation? Below that, casting at each call site is cheaper than a .d.ts file to keep current.
  2. Does the augmentation tie a branded ID or domain type to the third-party shape? When the cast you would otherwise write erases a brand the project authored, augmentation makes the brand permanent at the seam instead of restating it at every call site.
  3. Does the library document the type as extendable? Docs that name declare module (next-intl’s AppConfig, Auth.js’s Session) mark augmentation as supported. Augmenting a type the library treats as internal is risky: a minor-version bump can change the shape and break the augmentation.

Before any of these, ask question zero: does the library already ship its own extension contract? A generic type parameter (Drizzle’s schema generic), an inference helper (typeof auth.$Infer.Session), or a builder’s return type is the preferred path, because the runtime shape and the type shape both derive from the config you wrote and stay in sync. Reach for declare module only when the library ships no such mechanism, or when it documents augmentation as the path, as next-intl does for typed messages.

Canonical site 1: next-intl typed messages

Section titled “Canonical site 1: next-intl typed messages”

next-intl documents declare module 'next-intl' as the supported way to tie its message keys to your translation JSON. With it in place, useTranslations('home') autocompletes the keys under home and t('title') is type-checked against the JSON’s shape.

types/next-intl.d.ts
import type messages from '@/messages/en.json';
declare module 'next-intl' {
interface AppConfig {
Messages: typeof messages;
}
}

One file per package, outside src/, named for the specifier it augments, so anyone asking “where do we type next-intl?” finds it on the first guess.

types/next-intl.d.ts
import type messages from '@/messages/en.json';
declare module 'next-intl' {
interface AppConfig {
Messages: typeof messages;
}
}

A type-only import of the English messages, your source of truth. The top-level import makes the file module ambient so the augmentation fires, and import type keeps it out of the runtime bundle.

types/next-intl.d.ts
import type messages from '@/messages/en.json';
declare module 'next-intl' {
interface AppConfig {
Messages: typeof messages;
}
}

AppConfig is the interface next-intl publishes for projects to extend; its docs name the keys it accepts (Messages, Locale, Formats). Whatever you declare inside interface AppConfig { ... } merges into it.

types/next-intl.d.ts
import type messages from '@/messages/en.json';
declare module 'next-intl' {
interface AppConfig {
Messages: typeof messages;
}
}

typeof messages derives the type from en.json. Add a key and the type updates; rename a key and every stale t('...') call turns into a red squiggle, the moment the augmentation pays for itself.

1 / 1

The rest of the next-intl setup comes in the internationalization chapter.

Canonical site 2: narrowing a Drizzle relation

Section titled “Canonical site 2: narrowing a Drizzle relation”

Drizzle’s relations() helper infers relation types from your schema. When the foreign-key column is nullable, the inferred relation is Customer | null, even on a query path that always joins the customer. Every consumer then narrows it with ?. or a non-null assertion, for a guarantee the query already enforces. Module augmentation moves that narrowing to one place.

type InvoiceWithCustomer = typeof db.query.invoices.findFirst.$inferResult;
// { id: string; customer: Customer | null; ... }

The FK column allows null, so Drizzle widens the relation to nullable. Every consumer reads result.customer?.name or asserts non-null, and the real invariant, that this path always joins, stays implicit.

The exact interface depends on the Drizzle version and relation shape, so look it up in your installed types. The pattern is what carries over: narrow at the seam where the inference is published, not at every call site that reads the result.

This is narrowing, not lying: it holds because the query path enforces the join. Add a future path that skips the join and the type still claims non-null, so the bug ships as an undefined.name crash at runtime. That is the cost: one centralized assertion plus the debt of remembering the invariant whenever you add a query path.

Counter-example: Better Auth’s $Infer, not declare module

Section titled “Counter-example: Better Auth’s $Infer, not declare module”

The drifting-cast bug at the top of this lesson was a Better Auth bug, and the obvious fix is to declare module 'better-auth' and brand Session.user.id as UserId. That works, but it is the wrong reach.

Better Auth extends the session shape through an additionalFields config plus the typeof auth.$Infer.Session inference helper. The config goes on the server auth instance:

lib/auth.ts
import 'server-only';
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
user: {
additionalFields: {
orgId: { type: 'string', input: false },
role: { type: 'string', input: false },
},
},
// ... database adapter, plugins, etc.
});
export type Session = typeof auth.$Infer.Session;

Consumers import Session from lib/auth.ts, and orgId and role are typed on it:

import type { Session } from '@/lib/auth';
const readSession = (session: Session) => {
// session.user.id, session.user.orgId, session.user.role — all typed
return { tenant: session.user.orgId, role: session.user.role };
};

additionalFields is Better Auth’s published extension contract: the library hydrates those fields onto the runtime session from the database, and $Infer picks them up into the Session type. A declare module 'better-auth' { interface Session { ... } } augmentation would instead claim the field exists while the library never hydrates it, so the type would say string where the value was undefined: a lie that reintroduces the bug the brand was meant to prevent.

So for the opening bug, brand session.user.id at the query boundary, where the bare string from the session crosses into application code, using the brand factory from chapter 5:

const archiveInvoice = async (session: typeof auth.$Infer.Session, formData: FormData) => {
const ownerId = userId(session.user.id); // re-brand at the boundary
return invoiceQueries.archive(ownerId, formData.get('invoiceId'));
};

The full setup lands in the Better Auth chapter; here, declare module 'better-auth' is the wrong tool because the library already ships a better one.

Three misuses look like augmentation but defeat its purpose.

These six scenarios drill both decisions from this lesson: augment versus narrow, and augment versus the library’s own extension path.

Sort each scenario by the right tool for the job. Drag each item into the bucket it belongs to, then press Check.

Augment via `declare module` The library documents augmentation as the path.
Use the library's own extension mechanism The library ships `$Infer`, a generic, or a config-driven shape.
Narrow at the call site No augmentation; check or assert where the value is read.
next-intl doesn’t autocomplete my message keys
Better Auth’s session.user needs a custom role field
A library’s fetchUser returns User | undefined, my call site assumes it exists
Drizzle infers a relation as nullable, but my query always joins
My own User type from @/lib/types is missing an email field
A third-party analytics SDK installs window.posthog — I need to call it from a Client Component