Only one structural directive per element
Before we dive into the details of Angular's micro-syntax, it's worth pointing out a key constraint: a host element can carry just a single structural directive. The official documentation explains the reasoning: simplicity. Structural directives can perform complex operations on the host element and its descendants. If two directives both claim the same host, which one should get precedence?
Because of this rule, attempting to place multiple structural directives on one element causes the Angular compiler to raise an error.
Breaking down the syntax reference
I spent considerable time pondering the most effective approach to explaining Angular's micro-syntax.
Should I mirror the way I first learned structural directives? That path involved gradually encountering new pieces and quirks as I worked with them, but it left me without a thorough, foundational grasp of the underlying mechanics.
Alternatively, I could begin with the official syntax reference—daunting as it is. I've always found that specific section of the Angular docs particularly perplexing. Yet once it finally clicked, it felt like a revelation, fundamentally reshaping how I interpret and work with structural directives.
I've decided to go with that route: starting from the official reference. I'll break it down methodically and walk through each component. Together, we'll decode it and push our Angular expertise further.
Let's get into it.
Now then: the structural directive syntax reference
When I first encountered this expression in the docs, I couldn't make sense of any of it, and I felt swamped. But armed with what we've already covered, plus a bit of color-coding and splitting this beast into manageable chunks, we can untangle the syntax and see exactly what's happening.
Adding color makes the individual sections stand out more.
That's clearer! I also want to swap the order of the initial :let and :expression parts. The rationale will become obvious shortly.
Much better! This already feels far less overwhelming.
Breaking down the structural directive syntax reference
Let's examine the syntax piece by piece.
The equals sign splits the expression into two distinct sides:
1. *:prefix

On the left side, we have the asterisk * combined with the :prefix.
The asterisk signals Angular to wrap the directive inside an ng-template.
:prefix always corresponds to the directive's selector. When the directive declares an input that matches the name of our :prefix AND the right side begins with an :expression, the prefix can double as the first input for the directive.
More detail on this appears in the upcoming section.
2. (:expression | :let) (';' | ',')? (:let | :as | :keyExp)*

The right side consists of multiple sub-groups. Blue parentheses mark each logical group. A blue vertical line | represents a logical OR inside a group. A blue question mark ? following a group signals that it is optional. A blue asterisk ***** after a group means the group may appear any number of times, including zero.
The right side can be split into three smaller parts:
1. (:expression | :let)

This initial part must be either an :expression or a :let declaration.
1.1 :expression

When the directive has an input parameter that matches the selector name, this part must always be an :expression, otherwise Angular raises an error.
Any valid Angular expression qualifies as an :expression — booleans (true/false), function invocations, or component properties all count.
Here's an illustration of how the micro-syntax assigns the :expression to the :prefix input of the component:
@Directive({
standalone: true,
// our directives selector, which becomes the :prefix
selector: '[myNgIf]',
})
export class MyNgIfDirective {
// dependencies needed to render
private _vcr = inject(ViewContainerRef);
private _template = inject(TemplateRef);
private _isRendered = false;
@Input()
// an input with the same name as the selector
// binding the :expression to the :prefix (myNgIf)
set myNgIf(expression: boolean) {
this._isRendered = expression;
if (this._isRendered) {
this._vcr.createEmbeddedView(this._template);
} else {
this._vcr.clear();
}
}
}
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, MyNgIfDirective],
template: `
<button (click)="isRendering = !isRendering">Toggle</button>
<p>We can now bind an Angular expression like our component variable isRendering ({{isRendering}}) to our myNgIf input.
If isRendering is true we will see our component rendered below:
<div *myNgIf="isRendering">Rendered.</div>
`,
})
export class App {
public isRendering = false;
}
When no @Input() property aligns with the directive's selector, the first part must be a :let declaration.
1.2 :let declaration

In the earlier article, we saw let declarations placed directly on the ng-template. These extract variables from our context and expose them for use within the template.
The micro-syntax :let declarations serve the same purpose.
Their structure closely resembles what we saw before:
let local="export ';'?"
- local denotes the template variable name.
-
export points to the value the directive publishes under a specific name — this corresponds to one of the keys in our
contextobject! The exception is the$implicitkey, which is special. It gets assigned to 'let local' automatically. When we omit the ="export" part from our let declaration, the$implicitvalue flows directly into our local variable. - ';'? is an optional semicolon marking the end of the let declaration.
Consider this sample of a micro-syntax :let declaration:
@Directive({
standalone: true,
selector: '[myLet]',
})
export class MyLetDirective implements OnInit {
// dependencies needed to render
private _vcr = inject(ViewContainerRef);
private _template = inject(TemplateRef);
public ngOnInit() {
this._vcr.createEmbeddedView(this._template, {
$implicit: { hello: 'World from implicit' },
second: { hello: 'World from second' },
});
}
}
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, MyLetDirective],
template: `
<div *myLet="let ctx;">
<p>{{ctx | json}}</p>
</div>
<div *myLet="let secondCtx = second">
<p>{{secondCtx | json}}</p>
</div>
`,
})
export class App {}
Notice how we pull the $implicit value into the ctx template variable while also extracting the second key into secondCtx through our :let declarations. Observe the semicolon after the first declaration — it's optional. The second declaration works fine without one.
2. (; | ,)?

A semicolon or comma can optionally appear after the initial :expression or :let declaration to separate it from what follows. This helps readability but is by no means required.
Let's modify our straightforward directive from the :let declaration example to show that separated :let declarations with semicolons and commas work but remain optional.
@Directive({
standalone: true,
selector: '[myLet]',
})
export class MyLetDirective implements OnInit {
// dependencies needed to render
private _vcr = inject(ViewContainerRef);
private _template = inject(TemplateRef);
public ngOnInit() {
this._vcr.createEmbeddedView(this._template, {
$implicit: { hello: 'World from implicit' },
second: { hello: 'World from second' },
});
}
}
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, MyLetDirective],
template: `
<div *myLet="let context; let secondCtx = second">
semi-colon
<p>{{context | json}}</p>
<p>{{secondCtx | json}}</p>
</div>
<div *myLet="let context, let secondCtx = second">
colon
<p>{{secondCtx | json}}</p>
<p>{{secondCtx | json}}</p>
</div>
<div *myLet="let context let secondCtx = second">
nothing
<p>{{context | json}}</p>
<p>{{secondCtx | json}}</p>
</div>
`,
})
export class App {}
Placing a semicolon — or even a comma — between our :let statements improves clarity. Omitting these separators does not break anything; the code performs exactly the same.
3. (:let |:as | :keyExp)*

3.1 :let declaration

After the initial :expression, :let declaration, or optional separator, this third portion can repeat as many times as necessary. Each occurrence may be a :let, :as, or :keyExp declaration. The :let declaration behaves identically to what we already covered. Next up: the :as declaration.
3.2 :as declaration

Much like the :let declaration, :as pulls context variables out and connects them to the template.
Its syntax is nearly identical to the :let form:
export as local ';'?
-
export is the value the directive exposes under a particular name — one of the keys in our
contextobject! - local is the template variable name.
- ';'? is an optional semicolon indicating the end of the declaration.
You could think of :as as :let written in reverse order. Angular transforms both forms into the same simplified let construct: let-local="export" on the ng-template.
To see this in practice, let's rewrite our AppComponent so it uses an :as declaration instead of the second :let declaration:
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, MyLetDirective],
template: `
<div *myLet="let context; second as secondCtx">
nothing
<p>{{context | json}}</p>
<p>{{secondCtx | json}}</p>
</div>
`,
})
export class App {}
Now we have two mechanisms — :let and :as — for extracting context data into the template. When a property on our directive shares its name with the selector, we also know how to pass an @Input() value. However, directives often need more than one input. The :keyExp declarations cover exactly that scenario.
3.3 :keyExp declaration

The syntax closely follows the :let declaration pattern:
key ":"? :expression (as local)? ';'?
-
key ":"? represents an assignment to a specific
@Input()variable on the directive. Why "specific"? The name of the variable combines the literal key value with the directive's selector (the :prefix). The key gets appended to the :prefix in camelCase. So :prefix and key merge into :prefixKey — ngIf with else becomes ngIfElse. You can verify this in the Angular source code. Additionally, the colon marking the assignment — similar to assigning a value to a key in a JavaScript object — is optional.*ngIf="loaded; else loadingTemplate"and*ngIf="loaded; else: loadingTemplate"are equivalent. -
:expression is the Angular expression bound to your :prefixKey
@Input(). - (as local)? offers an optional way to reference the :expression, supplied as an
@Input(), directly in the template using the local variable. Critical point: For this mechanism to function, the directive'scontextobject needs a key that matches the camelCase-fused name. For instance, with<div *calculateAvg="let avg; data: (data$ | async) as testData">...</div>, thecontextwould need acalculateAvgTestDatakey so thattestDataexposes the right value. - ';'? is an optional semicolon concluding the declaration.
Let's inspect the complete source for the *calculateAvg example referenced above:
@Directive({
// directive to calculate the average
selector: '[calculateAvg]',
standalone: true,
})
export class CalculateAvgDirective {
// dependencies needed to render the template
private _vcr = inject(ViewContainerRef);
private _template = inject(TemplateRef);
// our average that we calculate whenever
// new data is provided
private _avg = 0;
// private reference to our data so we can calculate the
// average in the set function
private _data = [];
@Input()
// we want to be able to use the key data in our key expression
// we know that we need to camelCase-fuse our key with the directive name
// calculateAvg (:prefix) + data (key) => calculateAvgData (:prefixKey)
set calculateAvgData(values: number[]) {
// store data passed in
this._data = values;
const sum = this._data.reduce((a, b) => a + b, 0);
// calculate the average
this._avg = sum / Math.max(1, values.length);
// render template
this._vcr.createEmbeddedView(this._template, {
// make avg available through $implicit
$implicit: this._avg,
// make data available for as expression
calculateAvgData: this._data,
});
}
}
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, CalculateAvgDirective],
template: `
<div *calculateAvg="let avg; data: (data$ | async) as testData">
micro-syntax: {{avg}} is the average of {{testData}}
</div>
<ng-template
calculateAvg
[calculateAvgData]="(data$ | async)"
let-testData="calculateAvgData"
let-avg
>
ng-template: {{avg}} is the average of {{testData}}
</ng-template>
`,
})
export class App {
data$ = of([1, 3, 3, 1, 4, 0, 2, 2, 1, 3]);
}
I highly recommend examining the working code here.
It's perfectly normal if this feels overwhelming or ambiguous. :keyExp declarations are without a doubt the trickiest aspect of structural directives and their micro-syntax.
Looking ahead
We have now examined the full extent of the micro-syntax, having carefully worked through the official definition of the syntax. The goal has been to clarify exactly what Angular does beneath the surface when structural directives are encountered in templates. As helpful as this exploration has been, I find that inspecting the built-in directives is an ideal way to solidify what we have covered. In the next article, we will put our new understanding to use by examining how NgIf and NgFor actually employ this syntax.
Should this article prove valuable to you, consider sharing it so others can benefit as well. For those interested in keeping up with my work, you can follow me on Twitter or check out my projects on Github.



