Local Class Extensions
extend TypeName { ... } lets you write 'hello world'.capitalize() or [1, 2, 3].last()
without adding anything to String.prototype or Array.prototype. It works at compile time:
the transpiler rewrites each such call into a plain function call, in the current file only.
The limit: a call is rewritten only when the receiver is written out as a literal, such as
'hello'or[1, 2, 3].xs.last()on a variable is not rewritten and fails when it runs. The section "The limit you will hit" below explains why and what to do instead.
extend String {
capitalize() {
return this.charAt(0).toUpperCase() + this.slice(1)
}
words() {
return this.split(/\s+/)
}
reverse() {
return this.split('').reverse().join('')
}
}
extend Array {
last() {
return this[this.length - 1]
}
sum() {
return this.reduce((a, b) => a + b, 0)
}
}
extend Number {
clamp(min: 0.0, max: 1.0) {
return Math.min(Math.max(this, min), max)
}
}
console.log('hello world'.capitalize()) // Hello world
console.log([10, 20, 30].sum()) // 60
console.log(7.5.clamp(0, 5)) // 5
test 'string extensions' {
expect('hello world'.capitalize()).toBe('Hello world')
expect(''.capitalize()).toBe('')
expect('foo bar baz'.words()).toEqual(['foo', 'bar', 'baz'])
expect('abc'.reverse()).toBe('cba')
}
test 'array extensions' {
expect([1, 2, 3].last()).toBe(3)
expect([10, 20, 30].sum()).toBe(60)
}
test 'no prototype pollution' {
expect(typeof String.prototype.capitalize).toBe('undefined')
expect(typeof Array.prototype.last).toBe('undefined')
}
The test '…' { } blocks are TJS inline tests: they run with the example, and a failing one is
reported alongside the output. See inline tests.
extend is useful for String, Array and Number, the types whose values you can write as
literals. You may write extend Map, extend Date or extend one of your own classes, and it
compiles, but no call is ever rewritten for them: new Map().size2() fails at runtime with a
TypeError.
Why not assign to the prototype?
The usual way to give strings a capitalize() is String.prototype.capitalize = …. That
works, and it changes String for every piece of code in the page: your libraries, their
libraries, and any future version of JavaScript that adds a capitalize of its own with
different behaviour. This has happened. The array method now called flat was going to be
called flatten, but MooTools, a popular library, had already patched an incompatible
Array.prototype.flatten into enough websites that the standard had to pick another name.
An extend block gets the method-call style without the global change, which is what the
"no prototype pollution" test above checks. An ordinary function such as capitalize(s) avoids
the global change too; what extend adds is only the value.method() way of writing it.
What the transpiler does
The methods are collected into a plain object belonging to this file, and each call on a
literal is rewritten to an ordinary function call with the literal as this:
// You write
'hello world'.capitalize();
[1, 2, 3].last();
// TJS emits
__ext_String.capitalize.call('hello world');
__ext_Array.last.call([1, 2, 3]);
Nothing is added to any prototype, and the call costs the same as calling a function.
Rules for extension methods
Methods use
this, as they would in a class, so write them with method syntax:extend Array { last() { return this[this.length - 1] } // correct last() => this[0] // compile error: arrow functions are not allowed last = () => this[0] // compiles, but defines nothing }The last form is not reported.
[1].last()is then not rewritten and throwsTypeError: [1].last is not a functionwhen it runs.Parameter examples become defaults, not checks. In an ordinary TJS function,
min: 0.0means "required, must be a number" (see parameters). Insideextend, the samemin: 0.0means "defaults to 0, not checked". So7.5.clamp()usesmin = 0andmax = 1and returns1, and7.5.clamp('a', 5)returnsNaN, not an error. If the arguments matter, check them inside the method, or write an ordinary function, where the checks do apply.A method with the same name as a built-in one replaces it, on literals only. With
extend Array { map(f) { … } },[1].map(f)calls yourmap, whilexs.map(f)still callsArray.prototype.map. The same call then behaves differently depending on how the receiver is written, so choose names the built-in types do not use.asyncmethods work and return a promise like any async function:extend String { async fetchJSON() { return (await fetch(this)).json() } }lets you writeawait 'https://example.com/data.json'.fetchJSON().Several
extendblocks for the same type merge, so you can group extensions by topic.Extensions are file-local. Calls in another module, including one you import, are never rewritten to use them, and there is no supported way to share an
extendblock. For helpers you need in several files, export ordinary functions.
extend String {
shout() {
return this.toUpperCase() + '!'
}
}
// A second block for String adds to the first.
extend String {
initials() {
return this.split(' ').map((w) => w[0]).join('')
}
}
console.log('hello'.shout()) // HELLO!
console.log('Ada Lovelace'.initials()) // AL
console.log(typeof String.prototype.shout) // undefined: nothing was patched
The limit you will hit: only literal receivers are rewritten
The rewrite is a source transform: it runs on the text of the file, before the file is parsed
and before any type information exists. It sees xs.last() as a name followed by .last(),
with no way to know what xs holds, and a variable can be reassigned to something else
anyway. So it acts only where the text itself shows the type:
| Receiver | Rewritten? |
|---|---|
'text', "text", `text ${x}` |
yes |
an array literal, including one built by spread: [1, 2], [...xs] |
yes |
a number written as plain digits: 7, 7.5, 12.0 |
yes |
a negative number: -7.5.clamp(0, 5) |
yes, but the minus applies last: -5 |
(7).clamp(), 7..clamp() |
no |
a variable: xs, name |
no |
a call result: 'a'.shout().shout() |
only the first call |
an indexed element rows[1].last(), or a name ending in a digit v2.clamp() |
wrongly rewritten (see below) |
Chaining extension methods does not work. In 'a'.shout().shout() the second call is not
rewritten and throws a TypeError.
As in JavaScript, -7.5.clamp(0, 5) means -(7.5.clamp(0, 5)), so it returns -5, not 0.
Writing (-7.5).clamp(0, 5) does not help, because parenthesised numbers are not rewritten. For
a negative value, use an ordinary function.
When the call is not rewritten it runs exactly as written. No prototype was changed, so the
method really does not exist, and the call fails with a TypeError when it runs, not when it
compiles:
extend Array {
last() {
return this[this.length - 1]
}
}
// `xs: [0]` means: an array of numbers, for example [0].
function lastOf(xs: [0]) {
return xs[xs.length - 1]
}
const xs = [3, 1, 4]
try {
xs.last()
} catch (e) {
console.log('xs.last() failed:', e instanceof TypeError) // true
}
// Wrapping the value in a literal works, at the cost of a copy.
console.log([...xs].last()) // 4
// For values you pass around, an ordinary function is the honest choice.
console.log(lastOf(xs)) // 4
The string form of the same workaround is a template literal: `${name}`.capitalize().
Two receivers are rewritten wrongly instead of being left alone. Because the rewrite matches
text, it mistakes [1] in rows[1].last() for an array literal, producing
rows__ext_Array.last.call([1]), and the 2 in v2.clamp() for a number, producing
v__ext_Number.clamp.call(2). Both fail at runtime with a ReferenceError naming a variable
you never wrote (rows__ext_Array is not defined). This is a bug in the transform; until it is
fixed, avoid calling extension methods on indexed elements and on names that end in a digit.
When to use it
extend is a limited feature. It pays off where the receivers really are written in place:
string and array constants in tests, configuration tables, or small scripts, where
a toDate() extension lets you write '2026-11-06'.toDate() instead of toDate('2026-11-06'). For anything you store in a
variable or pass around, which is most code, an ordinary function does the same job and works
on every value.