"Angular provides better TypeScript support [than React] because it's written in TypeScript" - a statement that often comes up in the endless framework debate (I'm not gonna talk about). Unfortunately, it's not that easy. Angular itself had many untyped or partially typed APIs (remember when Angular finally introduced type-safe reactive forms in version 14?) and still introduces APIs that harm developer experience because of incomplete type-safety. Let's take a look at the newly released version 17 of Angular and some new features introduced in the last few major versions.
What's new in Angular 17
Angular recently released version 17 with some great new features like a new control flow syntax, deferrable views, improved hydration and new docs on angular.dev.
For more information about the features itself I recommend Minko Gechev's article Introducing Angular v17.
Type-safe switch statement
In Angular 16 and below, built-in directives like *ngIf
and ngSwitch
were used for conditional rendering. Angular 17 introduces a new control flow syntax (developer preview). The new syntax uses @if
and @switch
.
@if (isEnabled) {
<div>Enabled</div>
} @else {
<div>Disabled</div>
}
Apart from the unfamiliar syntax, it has a few advantages over the old syntax. It works out of the box without additional imports (of the directives), it's faster, and it provides better type-safety.
Let's say we have a Product
type which is a union type of either a book (with a pages
property) or a movie (with a duration
property).
type Product =
| { type: "book"; pages: number }
| { type: "movie"; duration: number };
In plain TypeScript, you can write the following switch statement and the actual type will be narrowed done inside each case
block:
// Here, `product` could be a book or a page
// We cannot access the `pages` or `duration` property
console.log(product.pages); // <-- INVALID
// But we can check the type property
switch (product.type) {
case "book":
// TypeScript knows that `product` is a book
return product.pages;
case "movie":
// TypeScript knows that `product` is a movie
return product.duration;
}
This is something that did not work in Angular Templates using the ngSwitch
directive. The actual type of product
was not narrowed down inside the ngSwitchCase
block:
<div *ngSwitch="product.type">
<ng-container *ngSwitchCase="'book'">
<!-- DOES NOT WORK -->
<!-- product is still of type Product -->
<p>Pages: {{ product.pages }}</p>
</ng-container>
<ng-container *ngSwitchCase="'movie'">
<!-- DOES NOT WORK -->
<p>Duration: {{ product.duration }}</p>
</ng-container>
</div>
The new control flow syntax finally makes this possible:
@switch (product.type) {
@case ('book') {
<!-- TypeScript knows that `product` is a book -->
<p>Pages: {{ product.pages }}</p>
}
@case ('movie') {
<!-- TypeScript knows that `product` is a movie -->
<p>Duration: {{ product.duration }}</p>
}
}
Deferrable views
The new @
syntax also brings deferrable views, views that are lazy rendered depending on some trigger, like when a condition is true or the content comes into the viewport.
Components rendered inside a deferrable view are also automatically lazy loaded for better performance. This finally makes it possible to lazy load components while keeping its inputs type-safe.
@deferrable (when isExpanded) {
<my-large-component [value]="someObj" />
}
This will lazy load MyLargeComponent
but still makes your inputs (here value
) type-safe. Previously, you would have used createComponent()
.
const ref = createComponent(MyLargeComponent);
ref.setInput("value", someObj);
setInput()
was introduced in Angular 14 but is still not type-safe, neither the name nor the value. There are runtime checks to validate the name but no checks at all for the value. And given the dynamic nature of Angular Component Inputs (e.g. you can alias inputs to a different name) this API will probably be untyped forever.
Signals
Signals were introduced in Angular 16 as developer preview and are now stable in Angular 17. The problem that the new control flow syntax solved for switch statements, is again introduced with Signals.
The value of a signal is accessed via function. As a consequence, TypeScript will not narrow down the type of the value if you call it multiple times:
const productSignal = signal<Product>({ type: "book", pages: 100 });
if (productSignal().type === "book") {
// Invalid, productSignal is not narrowed down to a book
return productSignal().pages;
}
In the example above we would have to store the current value in a variable and use it:
const product = productSignal();
if (product.type === "book") {
// Now it works
return product.pages;
}
This works, but is not really convenient and even worse to use inside component templates. There is an open issue on GitHub for this problem.
Host Directives
Angular 15 introduced a new Directive composition API that lets you apply directives to a component's host element from within the component class. This allows you to extract functionality into directives and reusing them across components, without the need for consumers to apply them manually as you had to do before.
The hostDirectives
property accepts a list of directives that are then applied on the component's host, like the MenuBehavior
component in this example:
@Component({
// ...
hostDirectives: [MenuBehavior],
})
export class MenuButton {}
It starts to get complicated, in the sense of TypeScript support, as soon as you want to forward input props to the directive. Let's say the MenuBehavior
accepts a position
input to specify the position of the menu. You want to forward this input property and rename it to menuPosition
:
@Component({
hostDirectives: [
{
directive: MenuBehavior,
inputs: ["position:menuPosition"],
},
],
})
export class MenuButton {}
The example above creates a new menuPosition
input on the component and forwards this to the position
input of the directive. Unfortunately, this feature again introduces a partially type-safe API.
The inputs
property is partially type-safe, resulting in a weird developer experience:
- 🔴 It's typed as
string[]
, giving you absolutely no hints what inputs exist on the directive while typing. - ✅ Providing an unknown input name will show the
directive
value as invalid because the directive doesn't have such an input. - ✅ Find All References on the directive input works properly. It will find usages of the component with this attribute, e.g.
<menu-button menuPosition="bottom">
. - 🔴 Rename Symbol on the directive input does not work at all. Neither the specified input on the component class nor the actual usages of the input are renamed.
- 🔴 Find All References on
inputs: ['position:menuPosition]
does not work at all, it does not find anything. - 🔴 Rename Symbol on
inputs: ['position:menuPosition]
doesn't work at all. Good luck if you have to refactormenuPosition
. - 🔴 Go to definition does not work when using the attribute in a template on the component.
Required inputs
Angular 16 finally introduced the possibility to mark inputs as required. Previously, there was no way to enforce inputs being provided when using a component.
Unfortunately, this feature doesn't play well with TypeScript. The required
attribute is set via decorator and TypeScript is not aware of that. You'll either have to set a default value or use the exclamation mark.
class MyComponent {
@Input({ required: true })
foo: string; // <-- Uninitialized, TS error
// Workaround using default value
@Input({ required: true })
bar = ""; // Workaround
// Workaround using "!"
@Input({ required: true })
baz!: string;
}
Summary
Angular definitely made some valuable improvements in version 17, that improve type-safety and developer experience. But even newer APIs don't work well with TypeScript and refactoring features integrated in the IDE.
In addition, it will take time for the ecosystem to catch up with the new features and syntax, like code formatting and syntax highlighting which are broken for now.