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
// 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.
// 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);
} // 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.
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.
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
displayLinkreadslink.withoutTracking().toString(), left to right, like URL’s own methods.- No wrapper to convert
- The receiver stays a
URL. Go’sHelpURLgets its method only afterHelpURL(*link), and needsurl.URL(…)to go back. - Found by autocomplete
- After the
declare globalmerge, typinglink.in an editor listswithoutTrackingnext tosearchParams. - 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=….
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.
Owns the policy
Which parameters come off, and that the result is a copy.
Import what they call
Page, search, share button, and their tests.
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.
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.
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.
| Where you’ve seen it | What it adds | Who sees it |
|---|---|---|
A polyfill for Array.prototype.at | A method on a built-in prototype, at runtime. | Every array in the page, in every package. |
SvelteKit’s app.d.ts | user on App.Locals, for the checker only. | Every event.locals in the app. Something still has to set it. |
An analytics tag’s Window typing | dataLayer declared on Window. | Every file the checker reads. The tag’s script creates the real array. |
| A Go named type | type 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.
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.
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.
| The change | A method on URL.prototype | The imported function |
|---|---|---|
| Forty call sites in packages you own chain URL calls | One 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 bundle | Whoever installs last decides both packages’ links. | Nothing moves. Each import names its own function. |
| A test renders the footer on its own | The test setup has to install the method first, or the call throws. | Import and render. |
| A browser ships a standard method with the same name | Your 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 service | Not 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.tsexportingwithoutTrackingis 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.
HelpURLis 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.