Skip to content
Chapter 6Lesson 4

Augmenting third-party modules

TypeScript module augmentation, extending a third-party package's published types with declare module, and knowing when the library already gives you a better path.

Your project has branded IDs. UserId is string & { readonly __brand: 'UserId' }. The brand exists so that a raw string can never be passed where a UserId is expected, because that substitution is the bug class 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 pulls the user ID off the session and hands it to a function expecting UserId now has to either cast at the call site (session.user.id as UserId) or weaken the receiving function to accept a bare string. Both moves undo what chapter 5 spent a lesson building.

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. It also teaches the habit that experienced engineers pair with it: read the library’s own extension contract before reaching for declare module. The mechanism is only a few lines of TypeScript syntax. The harder part is knowing when to use it and when the library has already given you a better path.

The lesson moves through four parts in order. The first is the drifting-cast bug that motivates the pattern. The second is the declare module mechanism itself. The third covers two canonical sites where augmentation is the right reach: next-intl typed messages and a narrowed Drizzle relation. The last is the counter-example of Better Auth, where the library ships its own extension contract and declare module is the wrong reach.

Both fixes below satisfy the type checker, but neither one 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 rewrites the type at this one site, with no enforcement anywhere else. A teammate copies the handler, forgets the cast, and the type checker stays quiet. The brand existed to prevent exactly the substitution the cast quietly allows.

The right reach 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 across the codebase, and the brand stays intact. That is what declare module exists for.

The pattern is a single block of syntax. Read it first, then we’ll walk through what each piece does.

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

The declare module 'some-library' { ... } block reaches into the declaration space of the 'some-library' package and merges its contents into the package’s already-published types. The merge happens at build time only. The .d.ts file ships zero runtime code, so the runtime behavior of some-library is unchanged. What changes is what TypeScript believes the library’s exported types look like. The mechanism is called declaration merging , and reaching into a third-party package this way is called module augmentation .

The interface keyword in that block matters. TypeScript merges interfaces with the same name, but it refuses to merge type aliases. If 'some-library' had published its surface as type User = { ... }, the augmentation would have nothing to merge into, because type aliases are closed by definition. So every library that wants to be augmentable publishes its extensible surface as interface. The course’s general TypeScript rule from chapter 4 still stands: type is the default for everything in app code. But interface is the form a library reaches for at its extension points, precisely because it is open.

The import type { UserId } from '@/lib/branded' at the top does more than fetch UserId. TypeScript treats a .d.ts file with at least one top-level import or export as a module, and declare module 'x' { ... } only works inside a module file. A .d.ts file with no top-level imports or exports is instead an ambient declaration that lives in the global namespace, and the two forms follow different rules. This distinction is the one place the pattern catches people out. If you write the augmentation without an import, TypeScript silently treats the file as a global ambient declaration. Now declare module 'next-intl' declares a non-existent global module named 'next-intl', the augmentation never fires, and nothing in the editor tells you why. The import type at the top is what keeps the file on the module side of the line.

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

Keep one file per package, so types/next-intl.d.ts rather than a catch-all types/global.d.ts. One file per package is easy to find from any call site, since the package name is the filename. It’s also easy to review on a library upgrade, and easy to delete when the upstream library adds the field natively and the augmentation is no longer needed.

Keep these files outside the regular src/ tree. The types/ directory makes the intent obvious: these are type-only seams to third-party packages, not application code. Putting them next to src/lib/ muddles that distinction.

The files are picked up by tsconfig.json’s include automatically. As chapter 24’s TypeScript-config lesson covers, the include array matches **/*.ts and **/*.d.ts, so the augmentation takes effect across the whole project as soon as you save the file. Your editor’s TypeScript language server picks it up after you restart the TS server, which you need to do the first time you add the file. In VS Code, that’s the TypeScript: Restart TS Server command from the palette.

Module augmentation has a cost. The .d.ts file lives in the repo and must be reviewed on every library upgrade. It can break silently when the library reorganizes the interface it merges into. And it adds one more place where the type system disagrees with what npm view <pkg> would show you. Before you reach for it, three questions should justify that cost.

  1. Does the third-party type appear in five or more places without the augmentation? Below that count, casting at each call site is faster to write and maintain than a .d.ts file you have to keep up to date.
  2. Does the augmentation tie a branded ID or a domain type to the third-party shape? Augmentation is the right reach when the cast you would otherwise write erases a brand the project authored. The brand is the whole point, and the augmentation makes it permanent at the seam instead of at each call site.
  3. Is the third-party type designed to be extended? Library docs that name declare module (next-intl’s AppConfig, Auth.js’s Session) are signalling that augmentation is the supported path. Augmenting a type the library treats as internal is risky: the library can change the shape between minor versions, and the augmentation then breaks under a pnpm update.

Before any of these three, ask question zero: does the library ship its own extension contract? Generic type parameters (Drizzle’s schema generic), type-inference helpers (typeof auth.$Infer.Session), or builder-pattern return types are the library’s preferred path. They keep the runtime shape and the type shape in sync because both derive from the same config you wrote. declare module is for libraries that haven’t shipped a better mechanism, and for surfaces like typed messages where the library explicitly documents augmentation as the path. The Better Auth counter-example at the end of this lesson shows this distinction in action.

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 the message-keys surface to your actual translation JSON. With it in place, useTranslations('home') autocompletes the keys under home, and t('title') is type-checked against the JSON’s shape. The augmentation file is four lines.

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

The file lives at types/next-intl.d.ts: one file per package, outside src/. The filename matches the import specifier (next-intl), so any developer 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 source-of-truth English messages JSON. This line does two things at once. It makes the file a module, since the top-level import is what flips the file from global ambient to module ambient. And import type keeps the file type-only, so the JSON is never actually read at runtime from here. The typeof messages on the next line works because TypeScript infers a precise structural type from the imported JSON.

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 the project to extend. The library’s docs name this interface and the keys it accepts (Messages, Locale, Formats). Anything declared inside interface AppConfig { ... } merges into the published AppConfig. That’s the declaration-merging rule from the mechanism section, applied at the seam the library documents.

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 the JSON file at the path you authored. Add a new key to en.json and the type updates. Rename a key and every stale t('...') call across the codebase turns into a red squiggle, which is the moment the augmentation pays for itself.

1 / 1

This is the course-canonical shape for the i18n typing seam. The full next-intl setup, including routing middleware, locale negotiation, and t.rich markup, lands in the internationalization chapter much later. Here you only need the augmentation shape.

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 underlying foreign-key column is nullable, the inferred relation comes out as Customer | null. This happens even when your application’s query path always joins the customer, so the runtime value is never null in practice. Every consumer of that query result then has to narrow it, with a ?. or a non-null assertion, for a guarantee the query already enforces. Module augmentation can move that narrowing to a single place.

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

Drizzle widens the relation to nullable because the FK column allows null in the schema. Every consumer reads result.customer?.name or asserts non-null. Either way, the query’s real invariant, that this code path always joins, stays implicit and has to be restated at every call site.

The exact interface name in your declare module 'drizzle-orm' { ... } block depends on the Drizzle version and the relation shape you’re narrowing. The snippet above is illustrative, and the Drizzle setup chapter later in the course names the concrete interfaces you’d target. What matters for this lesson is the pattern: narrow at the seam where the inference is published, not at every call site that reads the result.

One detail is worth watching before you move on. This is narrowing, not lying: the narrowing holds because the query path enforces the join, not because the data is genuinely non-null. So if a future change adds a query path that doesn’t join, the type still claims non-null and the bug ships at runtime as an undefined.name crash. The risk is the same as a non-null assertion at every call site, just centralized in one place. The augmentation does carry a small maintenance debt that the cast doesn’t, because you have to remember the invariant whenever you add a new query path. But the cast avoided that debt only because it offered no guarantee in the first place.

Counter-example: Better Auth ships its own extension mechanism

Section titled “Counter-example: Better Auth ships its own extension mechanism”

The drifting-cast bug at the top of this lesson was a Better Auth bug. After everything you’ve read so far, the natural instinct is to augment declare module 'better-auth' to brand Session.user.id as UserId. That augmentation would work, but it is not the right reach.

Better Auth’s 2026 idiom for extending the session shape is the 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 read Session from lib/auth.ts. The orgId and role fields are present in the type because $Infer.Session walks the config you passed to betterAuth and synthesizes a session type with those additional fields included:

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 };
};

Here is why this is the right reach over declare module. 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 automatically into the inferred Session type. The TypeScript shape and the runtime shape stay in sync because they both derive from the same source: the config you wrote. Compare that to a hypothetical declare module 'better-auth' { interface Session { ... } } augmentation. It would tell TypeScript the field exists, but the library wouldn’t actually hydrate it at runtime, so the type would say string while the value was undefined. The augmentation would be lying, and it would re-introduce the bug class the brand was designed to prevent, just in a different shape.

The habit comes down to this: read the library’s extension contract first, and treat declare module as the fallback rather than the default. When a library ships $Infer, generics, or an additionalFields-style config, use that path. When the library documents declare module as the augmentation point the way next-intl does, reach for it. When the library does neither and the type mismatch is structural, augmentation is the fallback. The bar to clear is “the library has no other path,” not “I want to add a field.”

So for the specific bug at the top of this lesson, branding session.user.id as UserId, the project’s resolution is to brand the value at the query boundary rather than at the type-system seam. The query boundary is where the bare string from the session crosses into application code, and the brand factory from chapter 5 is what does the branding there:

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 pattern is covered in the Better Auth setup chapter later in the course. The takeaway for this lesson is that declare module 'better-auth' is not the answer.

Three reaches look like module augmentation but aren’t.

Here is one concrete win that shows the pattern paying off. You add types/next-intl.d.ts with the messages augmentation. A week later, a designer wants to rename home.title to home.heading in messages/en.json. You rename the key, and before you’ve even switched windows, the editor has flagged every t('home.title') call across the codebase as a type error.

Without the augmentation, the rename ships, the user sees a missing-translation placeholder in production, and a customer finds the bug in a screenshot. With the augmentation, that same bug class, stale translation calls after a key rename, has moved from runtime to compile time. The augmentation is a build-time investment that turns one class of runtime bugs into compile-time errors, and every site where you can make that trade is a site worth augmenting.

Six scenarios sort into three buckets. The exercise tests both halves of this lesson: the augment-versus-narrow call (when is a .d.ts file worth its weight?) and the augment-versus-library-path call (does the library already give me a better seam?).

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