Let's build out a small Angular application:
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);
When rendering this app, we get the following output in the DOM:
<my-app>
<h1>The welcome app!</h1>
<welcome-msg></welcome-msg>
</my-app>
Wait, where did our
p
tag go? Where did either of thep
tags go?!
Believe it or not, this is expected behavior in Angular. It's caused from an error in our code that's subtle yet important.
In this article, we'll explore:
- What's happening to cause this behavior
- Why this behavior is happening
- How to fix it in our code
- What a long-term fix in Angular itself might look like
What's happening?
In the previous code sample the keen eyed amongst you might have noticed that we made a typo on welcomeMessage
. This typo was such that we ended up calling a method that does not exist:
const users: Record<string, () => string> = {
corbin: () => 'Hello, world!',
};
// ...
welcomeMessage = users.crutchcorn();
While this error may have been caught by changing our users
type like so:
const users = {
corbin: () => 'Hello, world!',
} satisfies Record<string, () => string>;
It's not a guarantee that errors like this won't ever happen again.
Typos and other errors exist in codebases of all scales, and while there are steps you can take, preventing them entirely is impossible.
This error in particular is a TypeError
of crutchcorn is not a function
.
Let's replace this with a throw
type to see more directly what's happening:
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);
Here, you'll see that <p>Before</p>
renders, but neither the <p>🙈</p>
or <p>After</p>
do, just like before.
However, if you move the <p>Before</p>
tag to be after the <throw-an-error/>
component, like so:
<!-- Try hiding and showing this line -->
<throw-an-error/>
<!-- This never shows up -->
<p>Before</p>
<p>After</p>
You'll see that neither Before
nor After
renders anymore. Why is that?
Why does this happen?
If we take a step back for a moment and look at how Angular's compiler works, you'll learn that Angular takes a component template like this:
@Component({
selector: 'app-cmp',
template: '<span>Your name is {{name}}</span>',
})
export class AppCmp {
name = 'Alex';
}
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); })();
This code is taken from the Angular Blog on how the Angular compiler works.
Let's take this compiler knowledge and apply it to our code. This means that some code like the folllowing:
<p>Render before</p>
<component/>
<p>Render after</p>
Will be compiled by Angular to be akin to the following output:
renderBefore();
renderComponent()
renderAfter();
While this works, there's a problem: Any errors that occur during renderComponent
's class constructor instanciation will disrupt the flow of the following lines of code.
Why?
JavaScript Error Handling
If we think about how JavaScript's error handling works, we'll find that a thrown error acts as an early return
for a function:
function sayHi() {
throw "This is an error";
// This will never execute
console.log("Hello!");
}
This behavior is true and consistent even when nested:
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();
This pattern is true within Angular as well, and cannot be prevented or modified - it's part of JavaScript's core identity.
A note on error catching
It's worth noting, however, that you can prevent these errors from halting future lines of code by using a try/catch
to stop the propegation of the error:
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();
How JavaScript's Error Handling Impacts Angular
Back to Angular land, let's think about our render pseudo-code from before:
renderBefore();
renderComponent()
renderAfter();
Let's assume that renderComponent
throws an error in its constructor, like our throw-an-error
component from before:
But let's take a step back and evaluate what is happening: When component
is rendered, it throw
s an error. We can now think of this code akin to this:
renderBefore();
throw new Error();
renderAfter();
Because of this, and following JavaScript's early return implementation of thrown errors, the render template can never reach the renderAfter
.
This is why we can see elements rendered before our throw-an-error
component, but not elements after.
Why we need a fix
If you've been a developer in the Angular space for a while, you'll know about the Angular ErrorHandler
API, which allows you to log and otherwise keep track of the errors thrown in your app.
It's hugely useful if you need a global mechanism for tracking errors to a third-party, like Sentry.
This might lead us to ask: Why do we need this issue fixed in the first place?
Consider this edgecase: you have a header component that's shown on every page. This header component requires you to call some API to get some of the header information to show to the user. Maybe they have a profile that you want to show metadata on in the header.
But oh no! The API you relied on changed out from under you and now your header throws an error during the constructor!
Because of this error throwing behavior, rather than a single part of your app breaking visually, now every page that has a header will not render properly.
That might include some process critical flow that brings your company money - and a fix is subject to however long your developers need to both fix and deploy the hotpatch.
No matter how unlikely this scenario is; downtime is money. Remember, the first best time to prevent errors is at build time, but it's not the only place error handling needs to exist.
Other ecosystems' fix to this problem
While this might sound like an Angular-specific problem, other frameworks have had to explore the same problem of "error thrown during rendering process leading to broken UI".
One such example of an in-template error handler lives in React as 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>
The short-term fix
Luckily, while <component-here/>
will not recover from an error, we can manually wrap our internal createComponent
call in a try/catch
thanks to Angular's ViewContainerRef
API:
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);
We can even expand our error-catcher
component to handle and accept 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 });
}
}
The problem with the short-term fix
The problem with the short-term fix is a multi-folded problem:
- The syntax and tooling are not consistent with other Angular components
- Outputs and inputs are very loose typings now, which may lead to more bugs, not less
- Outputs are not bound in the same way, requiring weird
switch/case
style coding and manually typing
If these issues are a deal-breaker for you, like they are for me, let's explore what a longer-term solution (built into Angular) might look like.
The long-term fix
If you've been following the Angular developer-experience closely, you'll know that the Angular team is aiming to add New Control Flow primitives into the framework. These new control flow primitives are meant to:
- Make the core of Angular's output smaller
- Make adding new functionality to the core, of Angular easier
- Interop better with Angular's upcoming signals API
They look something like this:
@if (user.isHuman) {
<human-profile [data]="user" />
} @else if (user.isRobot) {
<robot-profile [data]="user" />
} @else {
<p>The profile is unknown!</p>
}
This is equivalent 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>
My proposal for the long-term fix is that we add in a new @try
/@catch
syntax into the core of Angular's Control Flow primitives:
@try {
<error-throwing-component/>
} @catch (e: any) {
<handle-error [error]="e"/>
}
To help make this vision a reality, I've created a GitHub issue that outlines this long-term solution and volunteers myself to work on it.
It would mean a lot to me if you gave a thumbs up reaction to the GitHub issue so that it gains more traction with the Angular team.
Top comments (0)