Building a Minimal Reproduction

Let's scaffold a compact Angular application to examine the issue:

import 'zone.js/dist/zone';
import { Component } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';

const users: Record<string, () => string> = {
  corbin: () => 'Hello, world!',
};

@Component({
  selector: 'welcome-msg',
  standalone: true,
  template: `
    <p>Corbin says: {{welcomeMessage}}</p>
  `,
})
export class WelcomeComponent {
  // 🤫
  welcomeMessage = users.crutchcorn();
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [WelcomeComponent],
  template: `
    <h1>The welcome app!</h1>
    <welcome-msg/>
    <p>That's all!</p>
  `,
})
export class App {}

bootstrapApplication(App);
Enter fullscreen mode Exit fullscreen mode

Upon rendering this application, the DOM output looks like this:

<my-app>
    <h1>The welcome app!</h1>
    <welcome-msg></welcome-msg>
</my-app>
Enter fullscreen mode Exit fullscreen mode

Hold on—where has our p element gone? Where did both of the p elements go?!

Surprisingly, this is precisely what Angular is designed to do. It stems from an error in our code that is subtle yet crucial to understand.

Throughout this discussion, we'll cover:

  • The cause behind this behavior
  • The reasoning for its occurrence
  • The corrective measures for our code
  • Potential long-term improvements within Angular itself

Examining the Trigger

In the code sample above, the perceptive among you may have spotted a mistake with welcomeMessage. The error was such that we invoked a nonexistent method:

const users: Record<string, () => string> = {
  corbin: () => 'Hello, world!',
};

// ...
welcomeMessage = users.crutchcorn();
Enter fullscreen mode Exit fullscreen mode

Although this error might have been prevented by changing our users type as shown:

const users = {
  corbin: () => 'Hello, world!',
} satisfies Record<string, () => string>;
Enter fullscreen mode Exit fullscreen mode

It's not certain that similar errors won't occur again elsewhere.

Typos and other defects exist in codebases of every size, and although various precautions can be taken, eliminating them completely is unattainable.

This specific error is a TypeError: crutchcorn is not a function.


Let's swap this out for a throw statement to observe more clearly what occurs:

import 'zone.js/dist/zone';
import { bootstrapApplication } from '@angular/platform-browser';
import { Component } from '@angular/core';

@Component({
  selector: 'throw-an-error',
  standalone: true,
  template: `<p>🙈</p>`,
})
class ErrorComponent {
  constructor() {
    throw 'This is an error';
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [ErrorComponent],
  template: `
    <p>Before</p>
    <!-- Try hiding and showing this line -->
    <throw-an-error/>
    <!-- This never shows up -->
    <p>After</p>
  `,
})
class AppComponent {}

bootstrapApplication(AppComponent);
Enter fullscreen mode Exit fullscreen mode

In this case, you'll observe that <p>Before</p> does render, but neither <p>🙈</p> nor <p>After</p> do, matching the earlier example.

However, if you relocate the <p>Before</p> tag to appear after the <throw-an-error/> component, as below:

<!-- Try hiding and showing this line -->
<throw-an-error/>
<!-- This never shows up -->
<p>Before</p>
<p>After</p>
Enter fullscreen mode Exit fullscreen mode

Then neither Before nor After renders any longer. What explains this?

Understanding the Root Cause

If we pause for a moment and examine how Angular's compiler operates, we see that Angular takes a component template like this:

@Component({
  selector: 'app-cmp',
  template: '<span>Your name is {{name}}</span>',
})
export class AppCmp {
  name = 'Alex';
}
Enter fullscreen mode Exit fullscreen mode

And compiles it into a template function:

import { Component } from '@angular/core';                                      
import * as i0 from "@angular/core";

export class AppCmp {
    constructor() {
        this.name = 'Alex';
    }
}                                                                               
AppCmp.ɵfac = function AppCmp_Factory(t) { return new (t || AppCmp)(); };
AppCmp.ɵcmp = i0.ɵɵdefineComponent({
  type: AppCmp,
  selectors: [["app-cmp"]],
  decls: 2,
  vars: 1,
  template: function AppCmp_Template(rf, ctx) {
    if (rf & 1) {
      i0.ɵɵelementStart(0, "span");
      i0.ɵɵtext(1);
      i0.ɵɵelementEnd();
    }
    if (rf & 2) {
      i0.ɵɵadvance(1);
      i0.ɵɵtextInterpolate1("Your name is ", ctx.name, "");
    }
  },
  encapsulation: 2
});                                                   
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(AppCmp, [{
        type: Component,
        args: [{
                selector: 'app-cmp',
                template: '<span>Your name is {{name}}</span>',
            }]
    }], null, null); })();
Enter fullscreen mode Exit fullscreen mode

This code is taken from the Angular Blog on how the Angular compiler works.

Let's apply this understanding of the compiler to our own code. This implies that some code like the following:

<p>Render before</p>
<component/>
<p>Render after</p>
Enter fullscreen mode Exit fullscreen mode

Will be compiled by Angular to approximately the following output:

renderBefore();
renderComponent()
renderAfter();
Enter fullscreen mode Exit fullscreen mode

Although this functions, there's a catch: any errors thrown during the class constructor instantiation of renderComponent will interrupt the execution of the subsequent lines.

What's the reason?

JavaScript Error Handling

Reflecting on how errors are managed in JavaScript, a thrown error operates as an immediate, forced exit for a function, functioning much like a return statement:

function sayHi() {
  throw "This is an error";
  // This will never execute
  console.log("Hello!");
}
Enter fullscreen mode Exit fullscreen mode

This principle holds consistently, even when functions are nested within other functions:

function sayHi() {
  throw "This is an error";
  // This will never execute
  console.log("Hello!");
}

function greet(name) {
  sayHi();
  console.log("My name is", name);
}

function greetWithName() {
  greet("Corbin");
}

// Will never `console.log`, instead will throw an error from `sayHi`
greetWithName();
Enter fullscreen mode Exit fullscreen mode

The same pattern applies within Angular, and there's no way to stop or alter it — it's inherent to JavaScript's fundamental nature.

A note on error catching

It's important to point out, though, that you can stop these errors from preventing subsequent code from executing by employing a try/catch block to halt the error's spread:

function sayHi() {
  throw "This is an error";
  // This will still never execute
  console.log("Hello!");
}

function greet(name) {
    try {
      sayHi();
  } catch (e) {
    // `e` is the thrown error
    // Log `e` to your error service, or do whatever you'd like to it
    // ...
  }
  // This code now continues as if nothing ever happened, rather than early returning
  console.log("My name is", name);
}

function greetWithName() {
  greet("Corbin");
}

// This will now log "My name is Corbin", without the "Hello!"
greetWithName();
Enter fullscreen mode Exit fullscreen mode

How JavaScript's Error Handling Impacts Angular

Returning to the Angular context, let's revisit the render pseudo-code from earlier:

renderBefore();
renderComponent()
renderAfter();
Enter fullscreen mode Exit fullscreen mode

Suppose the renderComponent function causes an exception to be thrown during its constructor, just like the throw-an-error component from our previous example.

Let's pause and consider what's happening: when component is rendered, it throws an exception. The code can now be understood as analogous to this:

renderBefore();
throw new Error();
renderAfter();
Enter fullscreen mode Exit fullscreen mode

Consequently, since thrown errors act as an immediate exit, the execution flow within the render template never gets a chance to reach the renderAfter section.

This explains why we observe parts of the template rendered before our throw-an-error component, while the sections after it remain missing.

Developers who have spent time in the Angular ecosystem are likely familiar with the Angular ErrorHandler API, which serves as a tool for logging and monitoring errors that are thrown within an application.

This API proves invaluable when you require a centralized approach to sending error reports to external services such as Sentry.

This raises the question: why is resolving this problem necessary in the first place?

Let's examine an edge case: a header component appears on every page of your application. This header somehow needs to request data from an API to display certain information. Perhaps you want to display metadata about the user's profile in the header.

But things take a turn! The API that your header depended on has changed unexpectedly, causing the header to throw an error during its construction process.

Due to this error propagation, instead of just a single segment of your application failing to display, every single page that includes the header is now rendered incorrectly.

This could encompass a critical business process that generates revenue — and the time to resolve this is dictated by the duration it takes your developers to implement a fix and redeploy.

No matter how improbable this situation appears; downtime often translates directly to financial loss. Although preventing errors at build time is the most effective strategy, it's certainly not the only point where robust error handling is required.

Other ecosystems' fix to this problem

Although this appears to be a challenge specific to Angular, it's not unique — several other frameworks have tackled the identical scenario of "an exception during rendering causing a corrupted user interface."

React provides a template-driven error handling solution in the form of an ErrorBoundary:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // Example "componentStack":
    //   in ComponentThatThrows (created by App)
    //   in ErrorBoundary (created by App)
    //   in div (created by App)
    //   in App
    logErrorToMyService(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return this.props.fallback;
    }

    return this.props.children;
  }
}

// ...

<ErrorBoundary fallback={<p>Something went wrong</p>}>
  <Profile />
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

Fortunately, although a component like <component-here/> cannot automatically recover from an error, we can take manual control. By leveraging Angular's ViewContainerRef API, we can directly wrap our internal createComponent invocation in a try/catch structure:

import 'zone.js/dist/zone';
import { NgIf } from '@angular/common';
import { bootstrapApplication } from '@angular/platform-browser';
import {
  Component,
  inject,
  OnInit,
  Input,
  ViewChild,
  TemplateRef,
  ViewContainerRef,
} from '@angular/core';

@Component({
  selector: 'throw-an-error',
  standalone: true,
  template: `<p>🙈</p>`,
})
class ErrorComponent {
  constructor() {
    throw 'This is an error';
  }
}

@Component({
  selector: 'error-catcher',
  standalone: true,
  imports: [NgIf],
  template: `
  <div *ngIf="error">
    <h1>There was an error</h1>
  </div>
  <ng-template #compTemp></ng-template>
  `,
})
class ErrorCatcher implements OnInit {
  @ViewChild('compTemp') compTemp!: TemplateRef<any>;
  @Input({ required: true }) comp!: any;

  containerRef = inject(ViewContainerRef);

  error: any = null;

  ngOnInit() {
    try {
      this.containerRef.createComponent(this.comp);
    } catch (e) {
      this.error = e;
    }
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [ErrorCatcher],
  template: `
    <p>Before</p>
    <error-catcher [comp]="comp"/>
    <p>After</p>
  `,
})
class AppComponent {
  comp = ErrorComponent;
}

bootstrapApplication(AppComponent);
Enter fullscreen mode Exit fullscreen mode

The level of control can be extended by enhancing the error-catcher component to also manage both incoming and outgoing data via inputs and outputs:

import { ErrorBoundary } from './error-boundary.component';

@Component({
  selector: 'child',
  standalone: true,
  template: `<button (click)="done.emit()">Child: {{name}}, {{age}}</button>`,
})
class ChildComponent {
  @Input() name!: string;
  @Input() age!: number;
  @Output() done = new EventEmitter();
}

@Component({
  selector: 'error',
  standalone: true,
  template: `<p>Error</p>`,
})
class ErrorComponent {
  constructor() {
    throw 'Failed to construct ErrorComponent';
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [ErrorBoundary, ChildComponent, JsonPipe],
  template: `
    <p>Parent</p>

    <hr/>

    <error-boundary [fallback]="fallback" [comp]="errorComponent"/>
    <ng-template #fallback let-error>
      <h1>There was an error in <code>errorComponent</code></h1>
      <pre><code>{{error | json}}</code></pre>
    </ng-template>

    <hr/>

    <error-boundary [comp]="childComponent" (event)="getEvent($event)" [inputs]="{age, name: 'Janie'}"/>
    <button (click)="count()">Count</button>
  `,
})
class AppComponent {
  childComponent = ChildComponent;
  errorComponent = ErrorComponent;

  age = 12;

  count() {
    this.age++;
  }

  getEvent(props: { name: string; value: unknown }) {
    console.log({ props });
  }
}
Enter fullscreen mode Exit fullscreen mode

Why the quick workaround falls short

The immediate solution introduces several complications that are hard to ignore:

  • Its syntax and tooling support diverge from what you'd expect when working with regular Angular components
  • Both inputs and outputs end up with very loose type definitions, which can easily introduce more errors rather than preventing them
  • Output binding works differently, forcing awkward switch/case patterns and manual type assertions

If these drawbacks are as unacceptable to you as they are to me, it's worth examining what a more permanent solution—one that's baked into Angular itself—could offer.

For those keeping an eye on Angular's developer experience evolution, the Angular team has been working on integrating New Control Flow primitives directly into the framework. These new primitives are designed to:

  • Reduce the overall size of Angular's core bundle
  • Streamline the process of introducing new core features
  • Work seamlessly with Angular's emerging signals API

Here's a glimpse of what they might look like in practice:

@if (user.isHuman) {
  <human-profile [data]="user" />
} @else if (user.isRobot) {
  <robot-profile [data]="user" />
} @else {
    <p>The profile is unknown!</p>
}
Enter fullscreen mode Exit fullscreen mode

This block of code is the direct counterpart to the following:

<human-profile *ngIf="user.isHuman; else elseOne" [data]="user" />
<ng-template #elseOne>
    <robot-profile *ngIf="user.isRobot; else elseTwo" [data]="user" />
    <ng-template #elseTwo>
  <p>The profile is unknown!</p>
  </ng-template>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

My suggestion for the durable fix is to introduce a new @try/@catch syntax as part of Angular's core Control Flow primitives:

@try {
    <error-throwing-component/>
} @catch (e: any) {
    <handle-error [error]="e"/>
}
Enter fullscreen mode Exit fullscreen mode

To push this proposal forward, I've filed a GitHub issue detailing the approach and have offered to implement it myself.

I'd really appreciate it if you could add a thumbs-up reaction to the GitHub issue to help it gain visibility with the Angular team.