Skip to content

Type Augmentation

Plumix names what your site registers, such as an entry type, a block or an image role, through TypeScript interfaces you extend by declaration merging. Once you declare a name, the API that reads it autocompletes it, types its data, and rejects a typo at compile time.

Each augmentable interface is a registry. Core seeds some of them with its own names, such as post and page in EntryTypeRegistry and featured in ImageRoles, and your code adds the rest. The runtime registration and the type declaration are two separate steps. registerEntryType("recipe", …) makes the type exist when the site runs, and the declaration makes it exist to the compiler. Each needs the other.

Every augmentation targets one specifier, plumix, the package you install. Never augment @plumix/core, another internal package, or a subpath such as plumix/plugin or plumix/theme, even when the API that reads the registry lives on that subpath.

Declare the name next to the code that registers it:

import type { ResolvedEntry } from "plumix";
declare module "plumix" {
interface EntryTypeRegistry {
insight: { entry: ResolvedEntry };
}
}

A theme then targets the type by name, and Plumix types data.entry from the projection:

import type { ResolvedEntry } from "plumix";
import type { EntryData } from "plumix/theme";
import { defineTemplate, defineTheme, forEntryType } from "plumix/theme";
declare module "plumix" {
interface EntryTypeRegistry {
insight: { entry: ResolvedEntry };
}
}
const insight = defineTemplate<EntryData>({
render: ({ data }) => <h1>{data.entry.title}</h1>,
});
export default defineTheme({
templates: [forEntryType("insight").template(insight)],
});

A name nothing declared is a compile error rather than a rule that never matches:

// Compile error: "insihgt" is not a key of EntryTypeRegistry.
forEntryType("insihgt").template(insight);

The value in EntryTypeRegistry is the projection, the type data.entry narrows to in that type’s templates. Declare a name with a projection that has no entry key and it falls back to the base ResolvedEntry.

Declaration merging joins two declarations of an interface only when both name the same module. The registries are re-exported through several packages, so augmenting plumix in a theme and plumix/plugin in a plugin builds two separate interfaces. Each keeps its own keys and misses the other’s, and nothing reports it. Augmenting @plumix/core is worse. Your project does not depend on it directly, so the declaration targets a module your compilation never loads, and it does nothing.

plumix re-exports every type, including the block and pattern registries whose functions live on plumix/blocks, so you can reach every registry through it.

Interface Maps Read by
EntryTypeRegistry entry-type name → { entry } projection forEntryType, feed.forEntryType, EntryMeta
TermTaxonomyRegistry taxonomy name → { term } projection forTermTaxonomy
ArchiveTypeRegistry plugin archive-type name → { data } projection forArchiveType
TemplateDepRegistry dep kind → key field and result type defineTemplate, defineTheme, registerTemplateDep
BlockTypeRegistry block name → attrs shape block()
PatternCategoryRegistry pattern category slug → true definePattern’s category
ReferenceHydrationShapes reference kind → hydrated shape reference fields
ImageRoles image role name → true .role(), registerImageRole, images
DebugPanelRegistry debug panel id → true the dev.panels setting
MailRegistry mail name → props defineMail, ctx.mail.send, mail overrides

Core seeds post and page, category and tag, the eight default pattern categories, the entry, term and user reference kinds, the featured and ogImage roles, its own debug panels and the magicLink, emailChange and mailTest mails. BlockTypeRegistry accepts any name, and types the attrs of a name it has not seen as JsonObject.

An event or filter you declare yourself needs no augmentation. Declare it as a value with defineEvent or defineFilter from plumix/plugin, list it in your plugin’s descriptor, and export it for other plugins to subscribe to:

import type { AppContext } from "plumix/plugin";
import { defineEvent, defineFilter, definePlugin } from "plumix/plugin";
interface Review {
readonly id: number;
readonly stars: number;
}
export const review = {
posted: defineEvent<{ readonly review: Review }>("reviews:posted"),
stars: defineFilter<number, { readonly review: Review }>("reviews:stars", {
sync: false,
}),
};
export const reviews = definePlugin("reviews", {
events: [review.posted],
filters: [review.stars],
setup: () => undefined,
});
export async function postReview(ctx: AppContext, posted: Review) {
const stars = await ctx.filters.apply(review.stars, posted.stars, {
review: posted,
});
await ctx.events.emit(review.posted, { review: { ...posted, stars } });
}

Another plugin imports review and subscribes in setup with ctx.events.on(review.posted, handler) or ctx.filters.add(review.stars, handler), and TypeScript types the payload, the value and the input from the declaration. Two declarations sharing an id fail boot. Core’s own subjects, such as block, user and auth, are declared the same way.

EntryMetaContributions, TermMetaContributions, UserMetaContributions and SettingsContributions map a meta box id to the fields it contributes. MetaOf, TermMetaOf, UserMetaOf and SettingsOf fold them into one typed record. Field Builders walks through it.

PluginContextExtensions types what extendPluginContext puts on every plugin’s setup context, and AppContextExtensions types what extendAppContext puts on every request’s ctx. PlumixEnv types the environment a runtime binds, so a config slot reads your keys with type checking. Secrets covers it.

A plugin can add options to what other plugins register. EntryTypeOptions, TermTaxonomyOptions, ArchiveTypeOptions, ListingArchiveTypeOptions and UnlistedArchiveTypeOptions are the option bags of the matching register* calls. @plumix/plugin-menu adds isShownInMenus to the first two this way. ThemeDescriptor takes the same treatment for a theme slot, which is how a rule kind adds one.

A declare module "plumix" block only resolves when the same file also imports from plumix. Without it, TypeScript reports that it cannot find the module. Most files import something from plumix already. A file that augments plumix but otherwise imports only from subpaths needs one import from the root, and a type is enough:

import type { HydratedReference } from "plumix";
interface ProductReference extends HydratedReference {
readonly sku: string;
}
declare module "plumix" {
interface ReferenceHydrationShapes {
product: ProductReference;
}
}

A declaration takes effect only in a compilation that includes the file declaring it. A plugin’s declaration that sits in a file your theme never imports, directly or through another import, is invisible to the theme.

  • A plugin published as a package. Put the declare module "plumix" block in the module that exports the types a theme imports from it, such as the result type of a template dep. Importing that type pulls the declaration in. A theme that touches only a plugin’s /server types never loads its main entry, so the compiler misses a declaration there.
  • A plugin defined inside your site. The theme cannot import from your config. Put the declaration in a shared file, such as plumix-types.d.ts, that both the config and the theme entry import.

The page for each registration covers the runtime half of its registry: Entry Types, Taxonomies, Templates and Rule Kinds for themes, Field Builders for meta, and Plugins for image roles and context extensions. Project Structure explains why plumix is the one import path.

Declare the entry types your plugins register, then open a theme and let forEntryType autocomplete them.