← Concepts & practices
Concept Design principles and language mechanisms

Delegation

Hold the helper, don’t become it.

You already write components that pass the rest of their props to an <input>. Let’s follow a blog editor’s tag list that extends Array to get map and length for free, until an import calls push and a post ends up with “JavaScript” and “javascript”.

TypeScriptGo One tag list, two implementations.

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
tags.ts
// 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.

TypeScriptReading
tags.ts
// 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();
	}
}
GoAlongside
tags.go
// 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.

Delegation

Who answers for the rules?

An array with rules. tags.add('Svelte') gives true; tags.add(' svelte ') gives false; tags.add('Web Components') gives true. Tags: svelte, web-components. Every tag follows the rules. Callers can reach add, plus every array method: push, unshift, splice, sort, reverse, fill…. add normalizes, refuses the repeat, and keeps the limit. The list renders with map and length for free.

01/ 05
Add three tags to ArrayTagList

Being an array is convenient.

ArrayTagList extends Array, and add applies the rules.

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

Read this scene

ArrayTagList extends Array, and add applies the rules.

An array with rules. tags.add('Svelte') gives true; tags.add(' svelte ') gives false; tags.add('Web Components') gives true. Tags: svelte, web-components. Every tag follows the rules. Callers can reach add, plus every array method: push, unshift, splice, sort, reverse, fill…. add normalizes, refuses the repeat, and keeps the limit. The list renders with map and length for free.

Watch restarts when you return. Step through keeps your selected step. Try it starts with an empty list each time you open it.

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 no push to 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 outside TagList changes.
Behavior from the delegate
PostTags keeps “TypeScript” as typed when the editor passes a different normalize.
Serialization on purpose
toJSON decides 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.

What happens when the author presses Enter?

The tag field needs two handlers, so someone passes the list’s methods straight through: { onAdd: tags.add, onRemove: tags.remove }. The author types “Svelte” and presses Enter, and the field calls handlers.onAdd('Svelte').

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.

TagInput

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.

<input>

Does the typing

Focus, selection, and every attribute it’s given.

Caller

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.

tag-rules.ts
// 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.

ReactAlready in your code
TextField.tsx
// 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.

Familiar code, what it holds, and what it hands to the thing it holds
Where you’ve seen itWhat it holdsWhat it hands over
A TextField componentAn <input>Every prop except its label
Go’s bufio.ReaderAn io.ReaderThe actual reads, which it buffers
Go’s http.StripPrefixA handlerThe request, after removing the prefix
A struct embedding sync.MutexThe mutexLock and Unlock, promoted to every caller
TagListA private arrayStorage, 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.

How a change affects a tag list that extends Array and one that holds an array
The changeExtends ArrayHolds an array
Render with mapWorks as is.Spread first: [...tags].map.
Import an old post’s tagsWhatever the importer calls.Through add, with a report.
Store tags in a SetCallers rely on array methods.Change one class.
Save the post as JSONWorks 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

Take the tag list into your editor. Fix tagFieldHandlers, change TagList to store a Set, and check that no caller had to change.

Back to Concepts & practices →