01 / The idea
A tag list that is an array is a fair start.
You’re building the post editor for a blog. Tags are lowercase words joined by hyphens, with
no repeats and at most five. ArrayTagList extends Array<string> and adds one method, add, that applies those rules. The editor renders the tags
with map, shows length, and saves them with JSON.stringify, all for free.
Read the first versionTypeScript · the version this lesson starts from
// The first version: the tag list is an array, with one method that applies the rules.
// Rendering gets map, length, and join for free.
export class ArrayTagList extends Array<string> {
add(raw: string): boolean {
const tag = slug(raw);
if (tag === '' || this.includes(tag) || this.length >= LIMIT) return false;
this.push(tag);
return true;
}
} Go has no extends, so its first version embeds *list.List, which
promotes every list method in the same way. Both languages meet again at TagList in section 02.
Then other code arrives. The import from the old blog has an array of tags and a list with a push method, so it calls push. A sort button in the admin view calls sort() and reorders the author’s tags in place. Nobody broke a rule on purpose; the list offered every
array method as a way around add, and nothing said not to use them.
Delegation means an object handles a request by handing the work to another object it
holds, instead of inheriting that object’s behavior. The tag list holds an array, keeps it
private, and forwards only add, remove, has, size, and iteration. Callers can only do what the list chose to offer, so
every path goes through the rules. It’s the “has-a” instead of “is-a” you’ll hear in reviews.
Section 05 builds a tag input that delegates everything but its tags to the <input> it renders, in React and Svelte.
02 / See the shape
Keep the helper private, and forward on purpose.
The basic form holds the array in a private field and forwards five things. In the wild also delegates normalizing to a function the editor passes in, imports old tags through the rules, and forwards serialization. At the call site runs typed tags and an old post’s tags through both versions.
Both languages produce the same tags, rejections, and JSON.
TagList holds its array privately and forwards add, remove, has, size, and iteration. Nothing else about the array is reachable.
// The list holds an array and hands it only the work callers need. The rules can't be skipped.
export class TagList {
#tags: string[] = [];
add(raw: string): boolean {
const tag = slug(raw);
if (tag === '' || this.#tags.includes(tag) || this.#tags.length >= LIMIT) return false;
this.#tags.push(tag);
return true;
}
remove(tag: string): void {
this.#tags = this.#tags.filter((existing) => existing !== tag);
}
has(tag: string): boolean {
return this.#tags.includes(tag);
}
get size(): number {
return this.#tags.length;
}
// for...of and spreading are delegated to the array's own iterator.
[Symbol.iterator](): Iterator<string> {
return this.#tags.values();
}
} // TagList holds a slice in an unexported field and hands it only the work callers need.
type TagList struct {
tags []string
}
func (t *TagList) Add(raw string) bool {
tag := Slug(raw)
if tag == "" || slices.Contains(t.tags, tag) || len(t.tags) >= Limit {
return false
}
t.tags = append(t.tags, tag)
return true
}
func (t *TagList) Remove(tag string) {
t.tags = slices.DeleteFunc(t.tags, func(existing string) bool { return existing == tag })
}
func (t *TagList) Has(tag string) bool { return slices.Contains(t.tags, tag) }
func (t *TagList) Len() int { return len(t.tags) }
// All delegates iteration to the slice, for use with range.
func (t *TagList) All() iter.Seq[string] { return slices.Values(t.tags) } Reading the TypeScriptPrivate fields and forwarding
#tags can’t be read outside the class, so there’s no way to reach the array
except through the methods. [Symbol.iterator] returns the array’s own
iterator, which is what makes for...of and [...tags] work.
A private field is also invisible to JSON.stringify. MDN: “Only enumerable
own properties are visited.” toJSON forwards the array on purpose; the
story shows a TagList without it saving as {}.
Reading the GoEmbedding versus an unexported field
Go’s spec calls a method of an embedded field promoted: it’s reachable as x.f as if it were declared on the outer type. That’s why EmbeddedTagList has a PushBack nobody wrote.
TagList keeps tags []string unexported, forwards iteration
with All() returning slices.Values(t.tags), and PostTags forwards encoding with MarshalJSON, because encoding/json skips unexported fields.
03 / Try to break the rules
Watch which calls get past the rules.
Five steps, each calling the lesson’s classes. The dashed tags are ones that break a rule, found by checking the list as it is. Before each step, guess whether a bad tag gets in.
In Try it, pick a list and try push, sort, and the old import
yourself.
What delegation 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.
- Rules no caller can skip
- The import goes through
add, because there’s nopushto call. - A small surface
- Four members and iteration, instead of every array method plus one.
- Freedom to change what’s held
- Swap the array for a
Set, and nothing outsideTagListchanges. - Behavior from the delegate
PostTagskeeps “TypeScript” as typed when the editor passes a differentnormalize.- Serialization on purpose
toJSONdecides exactly what a saved post contains.
The review words are delegation, forwarding for a method that only passes the call on, composition or has-a for holding the array instead of being one, invariant for the rules every tag must keep, the delegate for the array or function doing the work, and method promotion for what Go’s embedding does. Section 08 covers what they cost.
04 / Try a decision
Handlers that lost their list.
The tag field needs an add handler and a remove handler, and the list already has both. The
code is in handlers.ts, and the lesson’s tests pin what happens.
05 / Give it a real job
A tag input that delegates to its input.
In the real editor, the tag field has to take a name, a placeholder, a hint
through aria-describedby, and a disabled state, like any input. It also owns
the tags and the keys that change them: Enter or a comma adds, Backspace in an empty field
removes the last.
Owns the tags and keys
Enter, comma, Backspace, the remove buttons, and one hidden input per tag under the
caller’s name, so the form submits the tags.
Does the typing
Focus, selection, and every attribute it’s given.
Chooses the rest
name, placeholder, hint, and its own key handler.
The example leaves out tag suggestions, pasting several tags at once, and saving the post.
Build UIs?Every wrapper component you write decides which props it keeps and which it hands to the element inside.
Where it already is in your components
A TextField that renders a label and passes everything else to its <input> is delegation. React’s guide describes components that “forward
all of their props to their children” with the spread syntax, and adds: “Use spread syntax
with restraint.” The textbook field keeps id and label and spreads the rest.
Svelte collects the rest with let { a, b, ...others } = $props(), and the
textbook spreads them onto the input. Its docs note that “An element or component can have
multiple spread attributes, interspersed with regular ones”, and the later one wins.
When you have to own it
Now it’s the tag input. It forwards the rest of its props to the <input>, but spreads them first and sets value and the key handler
after, so a caller can’t replace the two things the component owns.
A caller’s own key handler isn’t thrown away either. The component calls it first, and if the caller prevents the default, the component doesn’t add a tag. That’s delegation in the other direction: the component hands the caller the first say.
// The tag input's own decisions. Everything else about the input is delegated to the <input>.
export const LIMIT = 5;
export function normalizeTag(raw: string): string {
return raw.trim().toLowerCase().replace(/\s+/g, '-');
}
// Returns the same array when the tag is empty, a repeat, or over the limit.
export function withTag(tags: readonly string[], raw: string, limit = LIMIT): readonly string[] {
const tag = normalizeTag(raw);
return tag === '' || tags.includes(tag) || tags.length >= limit ? tags : [...tags, tag];
}
export function keyAction(key: string, draft: string): 'add' | 'remove-last' | null {
if (key === 'Enter' || key === ',') return draft.trim() ? 'add' : null;
if (key === 'Backspace' && draft === '') return 'remove-last';
return null;
}
A text field that owns its label and forwards every other prop to the input it renders.
// TextField owns its label. Every other prop is delegated to the <input> it renders.
export function TextField({
id,
label,
...rest
}: { id: string; label: string } & Record<string, unknown>) {
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} {...rest} />
</div>
);
}
export function PostTitle() {
return (
<TextField id="title" label="Title" name="title" required maxLength={120} autoComplete="off" />
);
}
06 / Recognize it elsewhere
Anywhere one thing holds another and passes work to it.
You’ve used all of these. For each one, find what’s held and what’s forwarded.
| Where you’ve seen it | What it holds | What it hands over |
|---|---|---|
A TextField component | An <input> | Every prop except its label |
Go’s bufio.Reader | An io.Reader | The actual reads, which it buffers |
Go’s http.StripPrefix | A handler | The request, after removing the prefix |
A struct embedding sync.Mutex | The mutex | Lock and Unlock, promoted to every caller |
TagList | A private array | Storage, search, and iteration |
The embedded mutex is the Go version of this lesson’s first mistake: anyone holding the
struct can call Lock. When you see a wrapper, ask which of the held thing’s
methods it lets through, and whether that was a choice.
07 / Already in your toolbox
Your languages already spell out the mechanics.
Three places to look. For each one, find what gets passed along and what doesn’t.
Go spec · Struct types
Embedded fields and promoted methods: exactly what an embedded type lets through, and how a method on the outer type hides one with the same name.
Read the spec ↗MDN · this
Why a method passed on its own stops pointing at its object, which is the exercise in section 04.
Read the reference ↗React · Passing Props to a Component
Forwarding props with the spread syntax, and why the guide asks you to use it with restraint.
Read the guide ↗A useful counterexample: forwarding everythingWhen delegation adds nothing
If TagList had no rules and forwarded every array method, it would be an array
with extra steps. Delegation earns its code when the holder decides something the held thing
doesn’t know. Without rules, use the array.
08 / The parts to watch
Delegation leaks as easily as it protects.
These are the places it still goes wrong.
A method passed on its own loses its object
MDN: “The value of this in JavaScript depends on how a function is invoked”.
Pass (raw) => tags.add(raw), not tags.add. Browsers enforce
it too: in Chromium, an <audio> element’s pause called on its own throws TypeError: Illegal invocation.
Returning the held object undoes it
get tags() { return this.#tags; } hands out the private array, and push is back. Return a copy, or an iterator.
Embedding promotes methods you didn’t choose
In Go, embedding is automatic forwarding of everything exported. That’s handy for a type that really is the embedded one plus a little, and a leak for one with rules.
Spread order decides who wins
Put {...rest} before the props a component owns. After them, a caller’s value or key handler silently replaces yours.
Private state needs forwarding to be saved
JSON.stringify writes {} for an object whose data is in
private fields, and Go’s encoding/json skips unexported ones. Forward serialization
on purpose.
Every forwarded method is code to keep
When callers want at(), someone writes the forward. That’s the price of
choosing the surface; pay it only for methods callers actually need.
09 / Make the call
What would you have to change tomorrow?
Give both tag lists a plausible change and follow the work it creates.
| The change | Extends Array | Holds an array |
|---|---|---|
Render with map | Works as is. | Spread first: [...tags].map. |
| Import an old post’s tags | Whatever the importer calls. | Through add, with a report. |
Store tags in a Set | Callers rely on array methods. | Change one class. |
| Save the post as JSON | Works as an array. | Needs toJSON. |
Callers want at() | Already there. | Write the forward. |
Hold and forward when your type has rules the thing it’s built on doesn’t know. A tag list with a limit and normalized names is the moment.
Extend or embed when every inherited method is still safe for your callers to use.
The question I’d leave beside the code is: which of the helper’s methods should my callers be able to call?
10 / Take the idea with you
Explain “JavaScript” and “javascript” without saying “delegation.”
“The tag list was an array, so the import used the array’s push and skipped our
rules. Now the list keeps its array to itself and only offers add, remove, and the read-only
parts, so everything goes through the rules.” In a review, the words are delegation, composition, and invariant.
Before moving on, jot down how the bad tags got in, why passing tags.add on its own
failed, and one class in your code that extends or embeds something whose methods it doesn’t want
callers to use.
Connections to follow nextRelated lessons
- Composition over inheritance is the design principle this lesson puts into practice.
- Making illegal states unrepresentable keeps bad data out with types rather than methods.
- Polymorphism is what lets the delegate
be swapped, like the
normalizefunction. - Decorator delegates to an object with the same interface, and adds behavior on the way.