Angular Material is probably the most popular UI Components Library in the Angular ecosystem. Amongst the components that Angular Material provides, MatTable is one of the most used ones. It looks good, is feature-packed, and works with different types of dataSource.
The Problem
Despite being a well-crafted component, MatTable runs into the same limitation as any other UI Library which is ng-template does not have strong-typed for its template variable. I am talking about this syntax here, which is pretty familiar to you if you’re using MatTable
1<ng-template let-someVar></ng-template>2<!--someVar does not have any type information-->To be able to provide top-of-the-line customizations to its consumers, MatTable does have to leverage the Dynamic Template system of Angular. Here’s a usage example of MatTable
1<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">2 <!--- Note that these columns can be defined in any order.3 The rendered columns are set as a property on the row definition" -->4
5 <!-- Position Column -->6 <ng-container matColumnDef="position">7 <th mat-header-cell *matHeaderCellDef>No.</th>8 <td mat-cell *matCellDef="let element">{{element.position}}</td>9 </ng-container>10
11 <!-- Name Column -->12 <ng-container matColumnDef="name">13 <th mat-header-cell *matHeaderCellDef>Name</th>14 <td mat-cell *matCellDef="let element">{{element.name}}</td>15 </ng-container>16
17 <!-- Weight Column -->18 <ng-container matColumnDef="weight">19 <th mat-header-cell *matHeaderCellDef>Weight</th>20 <td mat-cell *matCellDef="let element">{{element.weight}}</td>21 </ng-container>22
23 <!-- Symbol Column -->24 <ng-container matColumnDef="symbol">25 <th mat-header-cell *matHeaderCellDef>Symbol</th>26 <td mat-cell *matCellDef="let element">{{element.symbol}}</td>27 </ng-container>28
29 <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>30 <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>31</table>The above snippet is extracted straight from MatTable Documentations
These <td mat-cell *matCellDef="let element"></td> are horrible. It brings the Developer Experience down greatly because element has no type information whatsoever. Additionally, this is also one of the features that have been asked multiple times on the Angular repository
- https://github.com/angular/components/issues/22290
- https://github.com/angular/components/issues/16273
- https://github.com/angular/angular/issues/28731
While the Angular team does want to provide this enhancement, the technical limitation seems to stem from how the Angular Compiler works, so it is a little low-level and will pose a great challenge for either the team, or the community to contribute.
So, as developers, what can we do in this case? Well, there is a workaround that you can leverage to improve your team’s DX if you’re using Angular Material, specifically MatTable, in your applications. That is the power called Directive.
The Solution
Before we get to the actual solution, we need to analyze what we know and what we need to do first: - dataSource is the piece of data that is going to provide the type for element - matCellDef cannot infer the type of its parent’s input. There is no way for matCellDef to be like “Ok, for this template variable, I want to use the type of this Input from my parent”. - matCellDef is the selector of MatCellDef directive and is a Structural Directive, which means there is some syntactic sugar that we can leverage.
The above are the things that we can analyze from the consumers’ usage of MatTable. Besides these points, there is one more thing that we need to know. In Angular low-level area (Compiler, Language Service, etc…), there are a couple of static flags that we can use to accommodate the Language Service like ngAcceptInputType, or ngTemplateContextGuard. The latter is the one we will leverage.
With those in mind, let’s get started with creating a Custom Directive that effectively monkey-patches the MatCellDef
1import { CdkCellDef } from "@angular/cdk/table";2import { Directive, Input } from "@angular/core";3import { MatCellDef, MatTableDataSource } from "@angular/material/table";4import { Observable } from "rxjs";5
6@Directive({7 selector: "[matCellDef]", // same selector as MatCellDef8 providers: [{ provide: CdkCellDef, useExisting: TypeSafeMatCellDef }],9})10export class TypeSafeMatCellDef<T> extends MatCellDef {11 // leveraging syntactic-sugar syntax when we use *matCellDef12 @Input() matCellDefDataSource: T[] | Observable<T[]> | MatTableDataSource<T>;13
14 // ngTemplateContextGuard flag to help with the Language Service15 static ngTemplateContextGuard<T>(16 dir: TypeSafeMatCellDef<T>,17 ctx: unknown,18 ): ctx is { $implicit: T; index: number } {19 return true;20 }21}Let’s walk over each piece in this Directive
The Setup
- We make
TypeSafeMatCellDefto accept a type parameter<T>. selector: '[matCellDef]'We use the sameselectorasMatCellDef. This is our monkey-patch{ provide: CdkCellDef, useExisting: TypeSafeMatCellDef }Under the hood,MatTableis leveraging Angular CDK Table and is providingMatCellDefforCdkCellDeftoken. Here, we provide ourTypeSafeMatCellDeffor that same token soMatTablecontinues to work as expected.extends MatCellDefWe want our Directive to inherit the behavior ofMatCellDef
The Patch
@Input() matCellDefDataSource: T[] | Observable<T[]> | MatTableDataSource<T>;Although we do make our Directive to accept a type parameter<T>, we will never actually provide the type for the Directive directly since the instantiation of directives (or most building blocks in Angular) is Angular responsibility. ThisInputacts purely as a way for us to pass in the type information. Another thing to note here is that the name of the@Input(), we leverage another hidden feature of Angular.- If we name the Inputs of a Structure Directive prefixed with the selector, we can use the short-hand syntax when we use this Directive
1<div *ngFor="let item of list;trackBy: trackByFn"></div>trackByhere is actually@Input() ngForOfTrackBy
ngTemplateContextGuardis a TypeScript Type Guard. Here, we essentially say “ctx is of this type. Trust me TypeScript”
All that is left to do is to declare our TypeSafeMatCellDef directive.
Before: element is of type any
After: element is of type PeriodicElement which is the type of our dataSource
You can check out the Stackblitz below. However, it is hard to see the difference because of Stackblitz. You can try accessing an arbitrary property on element like element.fooBar, and you’ll see that the Compiler will fail to compile because fooBar is not a property of PeriodicElement.
Conclusion
Strongly-typed data is essential for a good and healthy Developer Experience. This has been proven by the risen popularity of TypeScript over the past couple of years. Although this solution works pretty well with not much custom code, I’m still looking forward to a more official solution from the Angular Team, and I am sure I’m not alone. Have fun and good luck 👋
Special thanks to @beeman_nl and @jefiozie for proofreading this blog post.