01 / The idea
Someone else needs to add a shortcode.
A page renderer meets tags such as [note]Back up first[/note] in a page and replaces
each one with an HTML fragment. With two shortcodes shipped by the theme, a switch in the renderer
is easy to read: match the tag name, pass the inner text to a function, splice in the result.
Later, a plugin wants to add [kbd] for keyboard keys. The renderer needs to find
it, and the editor’s tag picker needs to list it. You could keep adding branches to the switch
and editing the picker separately. As shortcodes gain different owners, it becomes useful to let
setup collect the available implementations in one place.
A registry records entries under names and lets other code find them later. Here, each entry is an existing shortcode function. The theme and plugins supply the functions; setup registers them; the renderer looks one up by the tag name it just read.
If you have used a web component, you have used one: customElements.define('shortcode-kbd', ShortcodeKbd) records a class under a tag
name, and the browser looks it up whenever that tag appears.
That separation gives you three decisions to name: what can register, what happens when a name is already taken, and what a caller receives when a name is missing. A map stores the associations. The registry’s contract explains how the application may use them.
Which meaning of Registry are we using?Scoped plugins and shared services
This lesson covers a scoped plugin registry: a directory for one kind of collaborator, passed explicitly to the part of the app that needs discovery. It does not need a single global instance.
In Martin Fowler’s Registry pattern, a well-known object provides access to common objects and services, often appearing global to its callers. That broader form explains the name’s connection to service location. We’ll keep the lookup responsibility while making our instance and dependencies visible.
02 / See the shape
Store the function. Return the function. Then call it.
The basic form is deliberately small. Put noteBlock under the name note, retrieve it, and call it with “Hello.” Registration does not create a
shortcode, and lookup does not run one.
In the wild adds the application’s rules: names have a defined spelling, duplicates fail, unknown names fail, and setup can close registration. The underlying map stays private so callers use those rules.
The shortcode contract is a synchronous string-to-string function: inner text in, HTML fragment out. Every sample escapes the text, then wrap it in an aside, a pre block, or a kbd element. The registry does not know what any of those fragments look like.
One map, one name, one existing function. Registration stores the note shortcode; lookup returns it; the renderer calls it. This small form permits replacement and leaves lifecycle decisions to its caller.
export type Shortcode = (text: string) => string;
// These sample shortcodes return HTML fragments, not complete documents.
function escapeText(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
export const noteBlock: Shortcode = (text) => `<aside class="note">${escapeText(text)}</aside>`;
export function basicExample(): string | undefined {
const entries = new Map<string, Shortcode>();
entries.set('note', noteBlock); // register an existing function
const shortcode = entries.get('note'); // lookup does not call it
return shortcode?.('Hello'); // the renderer invokes the function
}
// A bare Map permits overwriting. The next form makes that decision explicit. type Shortcode func(text string) string
// These sample shortcodes return HTML fragments, not complete documents.
var htmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
func escapeText(text string) string { return htmlEscaper.Replace(text) }
func NoteBlock(text string) string { return `<aside class="note">` + escapeText(text) + "</aside>" }
func BasicExample() (string, bool) {
entries := map[string]Shortcode{}
entries["note"] = NoteBlock // register an existing function
shortcode, ok := entries["note"] // lookup does not call it
if !ok {
return "", false
}
return shortcode("Hello"), true // the renderer invokes the function
}
// A bare map permits overwriting. The next form makes that decision explicit. The behavior every version promisesRegistration, lookup, discovery, sealing
A name starts with a lowercase ASCII letter and contains only lowercase letters, digits,
and hyphens. Spelling is exact: NOTE and note are rejected, not
normalized. These are our application’s conventions.
Registration checks whether setup is closed, whether the name is valid, whether the supplied value is callable where the language permits a missing value, and whether the name is taken. A failed registration changes nothing. The same function may be registered under two different names.
Lookup validates the name, then returns the registered function or a failure. names() returns a fresh list of names in ascending ASCII order, independent of registration order. An
empty registry is valid. Calling seal() repeatedly is harmless; once sealed, lookup
still works but new registrations fail.
Sealing is an extra application rule for a fixed setup phase. Registration and lookup are the core idea.
Reading the TypeScriptMap, private fields, function values
Map<string, Shortcode> maps names to callable values. The #entries and #sealed fields are private at runtime. A method can return a function
just as it can return a number; resolve(name) returns the shortcode and shortcode(text) invokes it.
get can return undefined, so the registry checks for a missing
entry. A shortcode that returns the empty string still exists. The map also treats constructor as an ordinary key, without the inherited properties of a plain object.
Returning a function preserves its identity and any captured state. The names array is copied, but shortcodes are not cloned. Declared string inputs are assumed rather than validated at runtime.
Reading the GoComma-ok lookup, pointers, returned errors
Shortcode names a function type. A map lookup can return both the value and
an exists boolean; that separates a missing key from the value’s zero value.
We reject a nil shortcode during registration.
The registry’s zero value is usable: its first successful registration allocates the
map. Methods use a pointer receiver. Pass *ShortcodeRegistry around and do not
copy the populated struct: a struct copy would share the map while copying the sealed flag
separately.
Registration and lookup return errors to the caller. Returning a shortcode copies the function value, which can still refer to shared captured state. Returning the names creates an independent slice.
03 / Follow the registration
Does a second registration change what note means?
The directory already maps note to the theme’s aside. Predict what happens when
the plugin’s keyboard-key shortcode tries to claim it, then press Register shortcode. Check both the feedback and the rendered fragment.
Next, register the keyboard-key shortcode as kbd and look up that name. Finally,
seal registration and try adding another name. Which operations still work?
Who owns the name note?
Try registering the plugin’s keyboard-key shortcode under a name the theme’s note aside already owns. Then try a new name.
The page renderer
Try note, a name you registered, or an unknown name such as quote.
Returned function → renderer calls it
<aside class="note">A & B</aside> The function registered under this name owns the fragment’s markup.
The input name and implementation are a proposed registration until you submit them. A rejected proposal leaves the current directory intact. The renderer keeps calling whichever function actually owns the tag name it read.
04 / Try a decision
A collision is an ownership question.
A map assignment can make a collision disappear from the code. Follow what each theme would believe afterward.
05 / Give it a real job
Setup assembles the directory. The renderer uses it.
At startup, bootstrap creates one registry and asks the active theme and the enabled plugins
to contribute their shortcodes. It handles registration errors, closes setup, and then
passes the registry to the page renderer. The editor’s tag picker gets its options from names(); each tag the renderer meets resolves its name and invokes the returned function on the
inner text.
Shortcode module
Provides a shortcode’s name and implementation. Owns the fragment’s markup and any shortcode-specific failure.
Scoped registry
Accepts unique names, reports collisions, lists entries, and preserves them after setup closes.
Page renderer
Owns the tag name it read, the inner text, invocation, and what the page shows when lookup fails.
A new [kbd] shortcode adds a function and a registration in the plugin’s setup.
The renderer’s lookup code stays the same, and the discovered name list includes kbd. Bootstrap still needs to know which plugins to load; a registry does not
discover code that has never been loaded.
One failed registration preserves existing entries. It does not roll back earlier successful registrations. The complete example stops setup on failure and never publishes the partly configured instance. A different application might disable an optional plugin and report why.
Each test can create its own registry. There are no import-time registrations or shared “current theme” variables in this example. That makes the setup visible and keeps one test’s entries from becoming another test’s starting state.
Build UIs?The browser keeps a registry of custom element names. Shipping a widget onto pages you don’t control makes its duplicate rule your decision.
Where it already is in your components
You may already know this rule if you have put a web component in a Vite app: save the
file that defines it, and instead of a hot update the console shows NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name
"shortcode-kbd" has already been used with this registry. A reload makes it go away. The reason is a registry the browser runs for you.
customElements is the page’s CustomElementRegistry. define records a class under a tag name, get looks one up, and
the browser uses that entry whenever the tag appears. Its rules are close to ours. The HTML Standard requires a name that starts with a lowercase ASCII letter, has no uppercase letters, and contains
a hyphen, so define('kbd', …) throws a SyntaxError. A name that
is already defined throws NotSupportedError and keeps its first class, and the
interface has no method that removes or replaces a definition.
A hot update is what runs the file twice. When you save it, Vite runs the new copy in the same page, where the registry still holds the old class. In our Vite 8.2.2 run, with a Svelte component importing the file below, the save logged that error and then “Failed to reload /App.svelte,” and the element kept its old markup until we reloaded.
// A component imports this file for its side effect. Save it while `vite dev`
// runs, and Vite runs it again in the same page, where the name is already taken:
// NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':
// the name "shortcode-kbd" has already been used with this registry
class ShortcodeKbd extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = '<kbd><slot></slot></kbd>';
}
}
customElements.define('shortcode-kbd', ShortcodeKbd);
Wrapping the call in if (!customElements.get('shortcode-kbd')) silences the
error. Svelte 5.57 emits that same check for a component with a customElement tag when it compiles for hot updates, which vite-plugin-svelte turns
on in development. The guard hides the collision without changing who owns the name: in our
run the page kept the old markup after the save and showed the edit only after a reload. That
is close to the exercise’s “ignore the new entry” answer, and during development it’s a fair
trade, because a reload starts a new registry.
When you have to own it
Now you ship a reviews widget. Shops add your script and a <store-reviews product="42"> element to their product pages, and you
don’t control those pages. A theme and an app can both include the script, or a cached
copy of version 2.1 can load before 3.0. Whichever copy loads second calls define for a name that is already taken, and throws.
So you choose the duplicate policy yourself. With the customElements.get guard, the first copy keeps the name: in our Chromium run, 2.1 loaded first, both copies ran
without an error, and the page used 2.1. That’s fine when any copy will do. If a newer version
has to win, remember that the registry can’t replace a definition, so the version belongs in
the tag name, or the newer copy should refuse to start and say why.
Lookup timing is the other decision. Our resolve fails for a name nobody
registered; the browser can wait instead. A shop’s own script that runs before yours finds
a plain element, and in our run calling widget.refresh() threw “TypeError:
widget.refresh is not a function.” customElements.whenDefined('store-reviews') returns a promise that is fulfilled with the class once the name is defined. Elements already
on the page are upgraded when define runs, so the same widget.refresh() worked after the promise settled. A name that is never
defined leaves the promise pending, so code waiting on an optional widget needs a timeout
or a fallback, not just an await.
06 / Already in your toolbox
Names connect independently owned pieces of software.
These public APIs expose registration and later discovery or use, each with its own collision and lifetime policy.
The browser’s CustomElementRegistry
define associates an element name with a constructor, and get retrieves its constructor when registered. Defining an already registered name
fails. The browser also rejects reusing a constructor in that registry; our example permits
one shortcode under different names. Element construction and lifecycle add responsibilities
beyond our directory.
Go’s database/sql
Register makes a driver available by name, Drivers lists
registered names in sorted order, and Open accepts a driver name. Duplicate registrations
and nil drivers panic. This package-level API illustrates registration; our scoped example instead
returns an error and lets setup decide what to do.
VS Code’s command registrations
commands.registerCommand associates a command ID with a handler. executeCommand invokes a command by ID. Registration returns a disposable that
unregisters the command, making removal part of the API. Our sealed registry leaves removal
out.
07 / The parts to watch
A name stays useful only while its meaning is clear.
Sealing the registry closes registration; it does not freeze every collaborator. A returned function can capture mutable configuration or hold a resource. Lookup returns that same collaborator, so its state and lifetime remain part of the shortcode’s contract.
This example has stateless functions and no cleanup to perform. If a shortcode owns a template cache or an open file, decide who shuts it down and what happens to callers that still hold it. Removing a name alone would not revoke a function already returned by lookup.
The registry becomes a service locatorHidden dependencies
A global services.get('mailer') inside an unrelated operation makes that dependency
hard to see from the operation’s parameters. It also asks every test to prepare the right global
environment. A registry can be used that way; its lookup mechanism does not prevent it.
Keep a narrow registry where runtime names are a real input. After resolving a known collaborator, pass it to the code that uses it. Explicit injection and a registry can coexist: one describes how dependencies are supplied, the other describes discovery by name.
Plugin loading becomes concurrent or reloadablePublication and removal
The Go example has no locks. Finish registration before publishing the pointer to readers through the application’s normal synchronization. Do not register or reseal concurrently. Sealing is a lifecycle rule, not a synchronization primitive.
In TypeScript, asynchronous module loading can still make registration order depend on timing. The owner governs access to this instance; sharing it across threads would require a suitable ownership and synchronization design.
For reloadable plugins, consider explicit replacement or unregister handles tied to a registration’s owner. An old cleanup callback should not delete a newer registration that reused the same name.
A name starts pretending to be a complete interfaceCapabilities and compatibility
Every shortcode here accepts inner text and returns a fragment synchronously. A plugin needing the page’s document tree, attributes on the tag, or asynchronous work has different requirements. A matching registration name does not make those interfaces compatible.
Names also become an API once they appear in saved pages. Renaming note may need
an alias or a migration. Make that choice explicit; silently lowercasing or falling back to
another shortcode can hide an unsupported tag.
08 / Make the call
Does the caller need discovery, or already know its collaborator?
I’d keep a fixed map or switch for two shortcodes maintained together. A registry earns its extra methods when separate features contribute named implementations and consumers need one shared policy for accepting and finding them.
| The situation | A useful starting point | What changes the choice |
|---|---|---|
| A small, closed set of shortcodes | A fixed map or switch. | Separate contributors need to extend the set without editing consumers. |
| A caller already knows the shortcode | Pass that shortcode directly. | The caller receives a name at runtime and must resolve it. |
| Themes and plugins contribute named shortcodes | A scoped registry with explicit collision rules. | Adding runtime removal or replacement requires ownership and lifetime rules too. |
| Each request needs a new configured instance | A factory, possibly stored in a registry. | Lookup and creation become separate steps; decide which one can fail and who owns the result. |
The wrapper is useful because it enforces an agreement, not because a map needs a more impressive name. If the agreement is already obvious at one construction site, keep that simpler code.
09 / Take the idea with you
Explain the directory without saying “registry.”
“Setup records each enabled shortcode under a unique name. The renderer lists those names, finds the function for the tag it read, and calls it. Two themes cannot quietly take the same name.” That explains both the mechanism and the decision.
Try adding a second theme with its own note shortcode to a site you know. Who chooses its public name? Who handles a collision? Then imagine uninstalling it while the renderer still holds the shortcode. Which promise must you add before removal is safe?
Connections to follow nextRelated lessons
- Strategy makes a policy replaceable through a shared contract. A registry can find that strategy by name; the consumer still delegates behavior to the returned function.
- Factory owns creation decisions. A registry may hold finished values or factories. Storing an existing shortcode does not create a new shortcode on each lookup.
- Dependency injection supplies collaborators explicitly. Inject a narrow registry where discovery is needed, or a resolved shortcode where it is already known.
- Multiton constructs one instance per key on a miss and shares its mutable state. This registry stores shortcodes a contributor already built, and lookup returns that function unchanged.
- Singleton retains one shared instance and answers every caller with it. A registry holds many named collaborators it did not build; making the registry itself the single global instance is the separate decision Fowler’s form makes.
- Command can represent work as a value. A command registry associates names with handlers; that alone does not provide queuing, undo, or a history of commands.