← Concepts & practices
Concept Design principles and language mechanisms

Extension

Add behavior to a type you do not own.

You already do this. If you’ve added user to App.Locals in a SvelteKit app.d.ts, or shipped a polyfill so an older browser gets Array.prototype.at, you’ve added to a type someone else owns. Let’s follow a help center’s link cleaner from a plain function to a method on URL, and see who else receives it.

TypeScriptGo One help-center link policy, two implementations.

01 / The idea

An imported function is a perfectly good first answer.

You’re preparing links for a help center. Articles arrive from newsletters and ads, so their URLs carry utm_source, utm_campaign, and gclid. Before the page shows or shares a link, those parameters come off.

stripTracking(link) does it. It takes a URL, returns a clean copy, and leaves the one it received alone. Whoever needs it imports it.

Read the first versionTypeScript · the code this lesson starts from
links.ts
// The first version is a scoped helper. It is clear, local, and works for one caller.
export function stripTracking(link: URL): URL {
	return cleanURL(link);
}

Go’s first version is StripTracking(url.URL), the same function with a capital letter. Both languages meet again at the basic form in section 02.

Then the policy spreads. The article page, the search results, and the share button all want it, and someone points out that stripTracking(link).toString() reads inside out. link.withoutTracking().toString() would sit beside URL’s own methods. TypeScript can do that: declare the method on the global URL interface, then assign it to URL.prototype.

An extension adds behavior to a type defined elsewhere, so callers use it through the original type’s shape without wrapping the value. That also answers who sees it. A method on URL.prototype belongs to every URL in the page, including the ones other packages create.

If you write components, you already live with both halves of this. The declare global block in your app.d.ts, or the one that adds dataLayer to Window for your analytics tag, tells the checker that a type you don’t own has more on it. The runtime half, assigning to a built-in prototype, is the one your linter stops: ESLint’s no-extend-native rule reads “Disallow extending native types.” Section 05 follows that rule into an article footer.

02 / See the shape

The dot needs two halves: a declaration and an install.

The basic form is the whole mechanism in TypeScript: the declare global block, installUrlExtension assigning the method, and the scoped withoutTracking(link) function beside it. Go’s basic form is the free function and a local HelpURL type that can carry the method. Switch to In the wild for the support link and its display, and At the call site for every way of calling the policy side by side.

Both languages print the same JSON, and both keep the remaining query parameters in the order the link had them.

TypeScript declares a method on URL, installs it on URL.prototype, and keeps a scoped free-function twin. Go cannot add a method to url.URL, so it uses a free function or a local named type.

TypeScriptReading
links.ts
// Declaration merging tells TypeScript about the method that the runtime patch installs.
declare global {
	interface URL {
		withoutTracking(): URL;
	}
}

export function installUrlExtension(): void {
	URL.prototype.withoutTracking = function withoutTracking(): URL {
		return cleanURL(this);
	};
}

// A scoped twin keeps the same behavior without changing URL for every importer.
export function withoutTracking(link: URL): URL {
	return cleanURL(link);
}
GoAlongside
links.go
// Go cannot add a method to url.URL because the type is defined in another package.
func WithoutTracking(link url.URL) url.URL {
	return cleanURL(link)
}

// A named local type can have a method, but every caller must convert to it first.
type HelpURL url.URL

func (link HelpURL) WithoutTracking() HelpURL {
	clean := WithoutTracking(url.URL(link))
	return HelpURL(clean)
}
Reading the TypeScriptDeclaration merging plus runtime installation

declare global merges withoutTracking(): URL into the URL interface the checker already knows. It installs nothing. installUrlExtension assigns the implementation to URL.prototype, and until it runs, the call type-checks and throws.

cleanURL builds a new URL, so neither the method nor the function changes its receiver. That is this example’s choice, not something extensions guarantee.

Reading the GoMethods only on types you define

Go’s spec says a method’s receiver base type “must be declared in the same package as the method.” url.URL is declared in net/url, so the method goes on HelpURL, a type this package defines. A url.URL has to be converted into it first, and converted back for any API that expects the standard type.

WithoutTracking(url.URL) works on the foreign type directly. Go rebuilds the query pair by pair rather than calling url.Values.Encode, which would sort the keys.

03 / Watch the scope

The method reaches every URL, not only yours.

Five steps, each running the lesson’s TypeScript. The board shows the foreign type and how far the behavior reaches. Before each step, guess whether a URL created somewhere else can call the method.

In Try it, clean links yourself, and load a second package that installs its own withoutTracking. The film and the lab take the method off again when they finish, so this page’s URL.prototype stays as the browser made it.

Extension

How far should one method reach?

Call a helper you own. Foreign type: URL. Scope: local function. stripTracking(link) → returns a URL; clean.searchParams → ref=home; link.search → tracking stays on input. The direct helper is explicit and scoped. It is the right amount of machinery for one caller.

01/ 05
Clean with a free function

A local helper is enough.

The function returns a copy and leaves the input URL alone.

Reduced motion: choose a scene to see its completed state.

Read this scene

The function returns a copy and leaves the input URL alone.

Call a helper you own. Foreign type: URL. Scope: local function. stripTracking(link) → returns a URL; clean.searchParams → ref=home; link.search → tracking stays on input. The direct helper is explicit and scoped. It is the right amount of machinery for one caller.

Watch restarts when you return. Try it starts with a fresh link.

What the method buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

A fluent call
displayLink reads link.withoutTracking().toString(), left to right, like URL’s own methods.
No wrapper to convert
The receiver stays a URL. Go’s HelpURL gets its method only after HelpURL(*link), and needs url.URL(…) to go back.
Found by autocomplete
After the declare global merge, typing link. in an editor lists withoutTracking next to searchParams.
One implementation
The method and the function both call cleanURL, so the four parameter names are written once.

None of that is free. Section 08 is the other side of the ledger: everything the imported function kept to itself.

04 / Try a decision

A second package picks the same name.

The help center installed URL.prototype.withoutTracking and moved its call sites to the dot. A month later the marketing team’s link package ships in the same bundle. It also installs a withoutTracking, one that keeps gclid because ad attribution needs it, and its module runs after yours. Both packages’ tests pass, each on its own. Then a support agent pastes an article link into a ticket and it still carries gclid=….

Which support-link call keeps your policy and leaves marketing’s alone?

Both packages assign URL.prototype.withoutTracking. Marketing’s module runs second and keeps gclid.

05 / Give it a real job

Give the policy one home, and install nothing by accident.

In the help center, links.ts owns the list of tracking parameters. The article page, the search results, and the share button import withoutTracking from it. Nothing has to run first, and a test of any of them imports the same function.

links.ts

Owns the policy

Which parameters come off, and that the result is a copy.

Callers

Import what they call

Page, search, share button, and their tests.

URL

Stays the browser’s

Parsing and serializing, with nothing added.

If you do keep the method, install it exactly once, in the entry file that runs before anything renders, on the server and in the browser, and again in the test setup file. Never install it from a component module: importing that component would change URL for the whole page, and a lazily loaded route would change it halfway through a session. Give it a name you own, so a second package can’t pick it by accident.

The example leaves out a full tracking taxonomy, consent, server redirects, and URL safety checks. None of those change where the behavior attaches.

Build UIs?Your app.d.ts already extends types you don’t own, and one day a share button makes you choose between a method and an import.

Where it already is in your components

SvelteKit hands you an app.d.ts with an empty App namespace for exactly this. Its docs say: “By populating these interfaces, you will gain type safety when using event.locals, event.platform, and data from load functions.” Adding user to Locals is declaration merging on a type the framework owns, and the framework invited it. React apps do the same for window.dataLayer.

The rule you already follow is the other half. You don’t write Array.prototype.last = …, and no-extend-native would flag it if you did. MDN calls that monkey patching and names the one exception: “The only good reason for extending a built-in prototype is to backport the features of newer JavaScript engines.” Your bundler’s polyfills are that exception.

When you have to own it

Now it’s the article footer. A reader arrives from a newsletter, so the page URL carries utm_source. The footer has a “Copy link” button, and a related-articles list whose links come from the CMS with campaign parameters of their own. Both need the help center’s policy, and link.withoutTracking() is right there, one assignment away.

The footer imports withoutTracking instead. The copied link and every related link go through the same function, the page URL it was given is not changed, and a test renders the footer without installing anything first. If marketing’s package lands in the same bundle, nothing in the footer moves. In React the function runs during render; in Svelte it sits in $derived, so a new page URL recomputes the link.

links.ts
const trackingParameters = ['utm_source', 'utm_medium', 'utm_campaign', 'gclid'];

// One policy, one home. Components import it; URL.prototype stays as the browser made it.
export function withoutTracking(link: URL): URL {
	const clean = new URL(link.href);
	for (const parameter of trackingParameters) clean.searchParams.delete(parameter);
	return clean;
}

A support link cleans its href with the imported function before it renders. URL keeps only what the browser gave it.

ReactAlready in your code
SupportLink.tsx
import { withoutTracking } from './links';

export function SupportLink({ href }: { href: string }) {
	const safe = withoutTracking(new URL(href, 'https://help.example'));
	return <a href={safe.toString()}>Open support article</a>;
}

06 / Recognize it elsewhere

You meet additions to other people’s types all the time.

URL is one receiver. Here are places you have probably added to a type you don’t own, or used someone else’s addition, and who ends up seeing it.

Familiar additions to types defined elsewhere
Where you’ve seen itWhat it addsWho sees it
A polyfill for Array.prototype.atA method on a built-in prototype, at runtime.Every array in the page, in every package.
SvelteKit’s app.d.tsuser on App.Locals, for the checker only.Every event.locals in the app. Something still has to set it.
An analytics tag’s Window typingdataLayer declared on Window.Every file the checker reads. The tag’s script creates the real array.
A Go named typetype HelpURL url.URL with its own methods.Only code that converts a value to HelpURL.

Check the last column first. The same syntax can reach one package or the whole page, and that reach is the design decision.

07 / Already in your toolbox

Your languages and frameworks draw the line in different places.

Three references to look at. For each one, find what is added and who receives it.

TypeScript · global augmentation

The handbook’s own example declares toObservable on Array<T> inside declare global, then assigns Array.prototype.toObservable. It is this lesson’s shape: the declaration for the checker, the assignment for the runtime.

Read global augmentation ↗

SvelteKit · the App namespace

A framework that declares empty interfaces so you can merge into them. You add fields to Locals or PageData; nothing runs, and the framework chose the names.

Look at app.d.ts ↗

Go · method declarations

A receiver base type “must be declared in the same package as the method,” so a foreign type gets a function or a local named type. The spec makes the free function the default.

Read the spec ↗
A useful counterexample: polyfillsSometimes patching a built-in is right

A polyfill assigns Array.prototype.at when the browser lacks it. That is a prototype extension in every sense, and it is correct: the method, its name, and its behavior are already decided by the standard, so nothing can collide with it later. MDN names this as “the only good reason for extending a built-in prototype.”

withoutTracking is the opposite case. The help center invented the name and the policy, and nobody else agreed to either.

08 / The parts to watch

A method on a foreign type has a larger audience than its author.

The imported function kept several things to itself. The method hands them to everyone who shares the page.

The prototype belongs to the whole page

Assigning to URL.prototype changes every URL in the current realm: the page, or the Node process on the server. A method installed by one package is not private to that package. Import order and test isolation now matter, which is why this page’s film and lab remove the method when they finish.

Two packages, or the language, can choose the same name

If two packages assign withoutTracking, the last assignment wins silently. The language can join in too. MooTools once added its own Array.prototype.flatten, and when browsers began shipping the standard one, pages broke; the standard method was renamed to flat to get around it.

The checker and the runtime can disagree

declare global only changes TypeScript’s view. If the install never runs, or runs after the first call, link.withoutTracking() type-checks and throws. Test the path that installs it, not only the declaration.

Adding a method doesn’t make you the owner

URL still belongs to the browser and the standard library. The method adds a call surface; it gives you no say over parsing, serializing, or what a future version adds.

A method can hide its inputs

The day a second product needs a different parameter list, withoutTracking(link, parameters) says what matters at the call, and only its importers see the change. A method can take the same argument, but its signature now lives on a global type, and changing it changes it for every package that calls it. Until then, link.withoutTracking() makes a product policy look like something URL always knew.

09 / Make the call

What would you have to change tomorrow?

Give both designs a plausible change and follow the work it creates.

How a change affects a method on URL.prototype and the starting imported function
The changeA method on URL.prototypeThe imported function
Forty call sites in packages you own chain URL callsOne fluent name, listed by autocomplete. The dot pays for itself.Every file imports it, and chains read inside out.
Marketing’s package ships in the same bundleWhoever installs last decides both packages’ links.Nothing moves. Each import names its own function.
A test renders the footer on its ownThe test setup has to install the method first, or the call throws.Import and render.
A browser ships a standard method with the same nameYour assignment replaces it, or its different behavior reaches your callers.Nothing changes. The import still names your function.
The same policy is needed in the Go serviceNot available on url.URL. Convert to HelpURL first.WithoutTracking(link), the same shape.

Reach for an extension when every caller is yours, the operation is small and stable, and the fluent call is used often enough to matter. Install it once, at the entry, under a name nobody else will choose.

Keep the imported function when the page loads code you don’t control, or the behavior is a product policy. Most link cleaners are both.

The question I’d leave beside the code is: who else receives this method, and would they agree it belongs on the type?

10 / Take the idea with you

Explain the link cleaner without saying “extension.”

“URL belongs to the browser. Our policy removes four tracking parameters and returns a copy, and every file that needs it imports it. Putting it on URL.prototype would give the method to every package in the page, and the last one to install it would decide what it does.” When the reviewer wants the word, it’s monkey patching: changing a type you don’t own for everyone who uses it.

Before moving on, jot down why marketing’s package changed your support links, why Go never had the problem, and one helper in your own code that someone has asked to “just put on the type.” The declare global in your app.d.ts counts.

Connections to follow nextRelated lessons
  • Module decides what a file exports. links.ts exporting withoutTracking is the scope this lesson keeps coming back to.
  • Mixin adds behavior to classes you define or construct. An extension adds it to a type defined somewhere else.
  • Adapter gives a foreign value the interface you need. HelpURL is a small one: convert in, call the method, convert back.
  • Delegation hands the work to a helper object you hold, instead of adding the method to the value itself.
  • Composition over inheritance asks where a behavior comes from and whether you can point at it. Here, the answer is an import.

Take links.ts into your editor. Add a second install that keeps gclid, swap the order of the two calls, and check which of the lesson’s links change. Then do the same with the imported function.

Back to Concepts & practices →