Most design systems hit the same wall around their tenth component. The button, the chip, the menu item and the tab all need to look disabled, set aria-disabled, refuse clicks when disabled, respond to Enter, and show a focus ring. The usual first attempt is a BaseInteractiveComponent class that everything extends. It works until the tab also needs to extend BaseSelectableComponent, and TypeScript gives you one base class per class.
The directive composition API, introduced in Angular 15, offers another route. Instead of inheriting behaviour, a component lists directives in its hostDirectives metadata, and Angular applies them to the component’s host element as if a consumer had written them in the template. Each directive keeps its own state, host bindings and listeners. The component just declares which ones it’s made of.
How hostDirectives differs from putting a directive in a template
If you write <ds-chip appDisableable> in a template, three things are true: the consumer had to remember the attribute, every input of appDisableable is bindable, and the selector must match. With hostDirectives, none of that holds:
- The directive is applied every time the component renders, decided at compile time. You can’t add or remove host directives at runtime, and there’s no conditional form.
- Angular ignores the directive’s selector entirely. Directives meant only for composition often have no selector at all.
- The directive has to be standalone. On Angular 19 and later directives are standalone by default; you just can’t set
standalone: falseon one. - Its inputs and outputs are not part of the component’s public API unless you list them.
That last point surprises people in interviews, because it’s the opposite of how template-applied directives behave. It’s also the feature that makes composition safe for a component library: you decide exactly what surface consumers get.
A worked example: a chip built from two behaviours
Here are two small directives. Neither has a selector, because they exist to be composed.
import { Directive, booleanAttribute, inject, input, output } from '@angular/core';
@Directive({
host: {
'[attr.aria-disabled]': 'disabled() ? "true" : null',
'[class.is-disabled]': 'disabled()',
},
})
export class Disableable {
readonly disabled = input(false, { transform: booleanAttribute });
}
@Directive({
host: {
role: 'button',
'[attr.tabindex]': 'state.disabled() ? -1 : 0',
'(click)': 'fire($event)',
'(keydown.enter)': 'fire($event)',
},
})
export class Pressable {
protected readonly state = inject(Disableable);
readonly pressed = output<Event>();
protected fire(event: Event) {
if (this.state.disabled()) {
event.preventDefault();
return;
}
this.pressed.emit(event);
}
}Pressable injects Disableable. That works because both are host directives on the same element, and Angular’s element injector lets directives on one element inject each other. It also means Pressable silently depends on Disableable being present; if a component lists Pressable alone, you get NG0201: No provider found for Disableable at runtime. You could make Pressable declare hostDirectives: [Disableable] itself so the dependency travels with it, which is how composition nests.
Now the component:
@Component({
selector: 'ds-chip',
template: '<ng-content />',
hostDirectives: [
{ directive: Disableable, inputs: ['disabled'] },
{ directive: Pressable, outputs: ['pressed: press'] },
],
})
export class Chip {}A consumer writes:
<ds-chip [disabled]="isArchived()" (press)="toggleFilter('archived')">Archived</ds-chip>With isArchived() false, the rendered element is:
<ds-chip role="button" tabindex="0">Archived</ds-chip>Clicking it or pressing Enter while it has focus each emit press once. Flip isArchived() to true and the element gains aria-disabled="true", the is-disabled class and tabindex="-1", and neither click nor Enter emits.
Walk through the decisions in that metadata. inputs: ['disabled'] exposes the input under its own name. outputs: ['pressed: press'] exposes the output under an alias, using the same original: alias syntax as old-style inputs arrays, because the library’s convention is (press). If you had written just hostDirectives: [Disableable, Pressable], both behaviours would still run, but a template binding [disabled]="true" would fail with NG8002: Can't bind to 'disabled' since it isn't a known property of 'ds-chip' at build time (in JIT test runs it shows up as the runtime equivalent, NG0303).
Run order and who wins a host binding conflict
Host directives always go first. For Chip with Disableable and Pressable, Angular:
- constructs
Disableable, thenPressable, thenChip - sets inputs and calls
ngOnInitin the same order - applies host bindings in the same order
Because the host component’s bindings are applied last, they override a host directive’s binding for the same attribute or property. If Disableable sets [attr.title] and Chip also sets [attr.title], the chip’s value ends up on the element. That’s deliberate: the component author has the final say over its own host element. It also tells you where not to put logic. If Chip’s constructor reads inject(Disableable).disabled(), it sees the default false, because inputs haven’t been set yet. Read it in a computed() or after ngOnInit.
The order extends through chains. If A lists B as a host directive and B lists C, construction runs C, B, A.
Providers and duplicate matches
Both a component and its host directives can declare providers. When they provide the same token, the component’s provider takes precedence. This lets a directive supply a sensible default service that a specific component can override.
Duplicates are where versions differ, so check which one your project uses. On Angular 19 and 20, if the same directive ends up matching an element twice (say a consumer writes <ds-chip appDisableable> while Chip already lists it as a host directive), Angular throws NG0309: Directive Disableable matches multiple times on the same element. Directives can only match an element once. Current angular.dev documentation describes newer behaviour that de-duplicates instead: a template match beats a host directive match, and multiple host directive matches merge into one instance. Either way, the safe design is to avoid exposing a behaviour both as a public attribute directive and as a host directive on the same components.
When composition is the wrong tool
Host directives add an object per directive per element. On a table with 2,000 cells each composed of four directives, that’s 8,000 extra instances, which can matter. They also make a component’s behaviour less visible: a reader has to open each directive to see what the host element does. For behaviour that only one component needs, put it in the component. For a single shared helper with no host bindings, a plain function or an injectable service is simpler. Composition earns its place when several unrelated components need the same host-level behaviour (attributes, classes, listeners) and you want each one to opt in explicitly.
Interview exercise: migrating off a base class
“Our library has BaseControl with disabled, focused and a (keydown) handler. Button, Chip and Tab extend it, and now Tab also needs selection behaviour that lives in a different base class. How would you restructure this, and what could break for consumers?”
Answer. I’d split BaseControl into focused directives (Disableable, Focusable, Pressable) plus a Selectable directive, each owning its own host bindings and listeners. Button and Chip would list the first three in hostDirectives; Tab would add Selectable. Each component then exposes only the inputs and outputs it lists, so I’d mirror the old public API exactly (for example inputs: ['disabled']) to keep consumer templates compiling.
Three things could break. First, anything that read inherited fields directly, like this.disabled inside Button, must now inject the directive and read its signal. Second, run order changes: directives construct and initialise before the component, so code in the component constructor that relied on initialised inputs would read defaults. Third, on Angular 19 or 20, any consumer who also applied one of those directives as an attribute on the same element would hit NG0309. I’d search templates for those attributes before shipping, and add a test per component asserting the rendered aria-disabled, tabindex and emitted outputs, so the refactor is verified against behaviour rather than class structure.