Why Can't Angular Cast An Element to Another?

March 6, 2025

863 words

Post contents

While I'm a huge fan of Angular at heart, I've often used React at my day jobs. While working with React component libraries - either internal or external - you're likely to run into a pattern like so:

<OurButton as="a" href="oceanbit.dev">This looks like a button, but is a link</OurButton>

This <OurButton/> component is able to expose an internal "button" tag when nothing is passed, but transform into any other as element when the property is passed.

What's cooler is that the other attributes from the as original element (like <a>'s href above) can be type-safe using some TypeScript magic:

<OurButton as="button" href="oceanbit.dev">This is a button!</OurButton>//                     ^ `href` is not allowed on type "button"

This is supported in both React as well as Vue!

Cool! How do you do that in Angular?

Well, in short, you can't. Not without effectively rewriting React or Vue inside of Angular itself.

With this feature being so ubiquitous as it is in React and Vue ecosystems, why doesn't Angular support it?

Well... Let's talk about that!

To start, let's make sure we're talking about the same Angular: starting with it's underpinnings.

Angular, the compiler

Angular, from the very start, has been a template compiled framework. From its heavy investment into the Ivy compiler all the way back in 2018 to the migration to AOT compiling as the default in 2019, its compiler is at the heart of Angular today.

So how does Angular's compiler work?

Assume we're compiling the following template:

<p>Hello, {{message}}</p>

This outputs to something like the following:

ɵɵdefineComponent({  type: TestCmp,  selectors: [['test-cmp']],  decls: 2,  vars: 1,  template: function TestCmp_Template(rf, ctx) {    if (rf & 1) {      ɵɵelementStart(0, 'p');      ɵɵtext(1);      ɵɵelementEnd();    }    if (rf & 2) {      ɵɵadvance();      ɵɵtextInterpolate1('Hello, ', ctx.message, '');    }  },  encapsulation: 2,});

This template function is then called multiple times, depending on the state of the render. If it's the first render, rf (short for "render flag") will be set to 1. If it's an update after the first render, rf will be set to 2.

Combined, these steps will output the expected DOM results.

You can explore the Angular compiler yourself here in an interactive site.

Angular, the tree

The compiler isn't the only trick Angular has up its sleeve to get things working as we'd expect, though. Angular also creates a tree to represent our DOM nodes:

TODO: Write alt

Here, a View Container represents any place where elements can be dynamically added or removed.

You can learn more about Views, View Containers, and templates in this article I wrote a while back.

These View Containers are often represented in the DOM by using an anchor node (typically a Comment node) and help facilitate Angular's instruction set to manipulate surrounding elements.

Two other examples where this occurs are:

  • Control flow syntax blocks (like @for and @if)
  • Structural directives (*ngElementNameOutlet)

Angular, the runtime

By now, it might be clear that Angular's runtime - while technically impressive and sound - is minimal after the compiler's done with its job.

To handwave a bit (or maybe more than "a bit"); Angular handles a flag to tell the runtime when to update the DOM in a given render flag stage, it executes the relevant component functions, then your UI is updated.

This is all to say "Angular has minimal control over how to create or update original DOM structures once it's hit the browser". Once it's past the compilation stage, the most Angular is capable of doing is creating a single DOM node that's not part of the existing template blueprint function from the compiler output.

This is partially how Angular is able to keep such slim and expedient runtimes; it has lower required overhead because it doesn't need to handle arbitrary DOM manipulation and can rely on persistent blueprints of your template to execute as needed.

This is also why the Angular team has been hesitant to roll out JSX as an authoring format for Angular; it's not a good fit as JSX's templating is intended to allow for dynamic transformation at runtime.

In order for there to be some kind of as casting API akin to an imaginary API of *ngElementNameOutlet="tag", we'd need a more expressive runtime component to Angular; which would instantly lead to heavy bloat in our bundles, extended initial JavaScript parsing time, and more.

Angular casting, the workaround

While Angular doesn't have a one-to-one API with React and Vue's as type cast, it does have the ability to attach component behavior onto different elements.

Because Angular uses host elements instead of transparent elements, we can tell Angular's selector to use an attribute to lookup rather than a new HTML tag:

@Component({	selector: `a[our-button], button[our-button]`,	template: `<ng-content></ng-content>`})class OurButton {}@Component({	selector: "app-root",	template: `		<a our-button href="oceanbit.dev">This looks like a button, but is a link</a>	`})class App {}

This means that we can customize the child elements and add functionality to our a or button tags that use the our-button attribute.

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.