Add some Typescript love to your Material dialogs

On every (non-personal) Angular project I've worked on, Material dialogs were a given. It's a shame that we are often not providing them the type annotation love they need.

Let's assume we encounter the following code:


export class ParentComponent {
  public constructor(private readonly dialog: MatDialog) {}
  
  public openConfirmDialog(): void {
    this.dialog.open(DialogComponent, {
      autoFocus: false,
      data: {
        id: 1
      }
    });
  }
}

First, let's have another look at the signature for dialog.open():


open<T, D = any, R = any>(component: ComponentType<T>, config?: MatDialogConfig<D>): MatDialogRef<T, R>

That raises two important issues which I've highlighted as comments below.


export class ParentComponent {
  public constructor(private readonly dialog: MatDialog) {}

  public openConfirmDialog(): void {
    this.dialog.open(ConfirmComponent, {
      autoFocus: false,
      data: {
        /* If you don't provide the type or interface for the data to pass to the ConfirmComponent, 
        Typescript will infer data as any */
        id: 1
      }
    })
      .afterClosed()
      .subscribe(result => {
        /* If you don't provide the possible values with which the dialog will be closed inside ConfirmComponent,
        Typescript will infer result as any */
      });
  }
}

The solution: generics

How do we solve this? Easy enough! According to the signature, Typescript allows you to pass the types it so desperately needs as generic type parameters. That's because .open() is a generic method.

In case you need a refresher about generics, or you have no clue what I'm talking about, I found this tutorial to be an excellent introduction.

Let's dive in:


export class ParentComponent {
  public constructor(private readonly dialog: MatDialog) {}

  public open(): void {
    this.dialog.open<DialogComponent, { id: number }, string>(DialogComponent, {
      autoFocus: false,
      data: {
        /* The line below will throw a compile-time error 
        because Typescript expects a number for the 'id' property */
        id: '1'
      }
    })
      .afterClosed()
      /* The line below will throw a compile-time error 
      because the return type is defined as a string */
      .subscribe((result: number) => {
        // do something
      });
  }
}

One of the main reasons why I always add the types, even though it's not mandatory, is because I once made a typo in a key of the data passed into a child component. When I found out that you could simply provide a type or interface for that data, I could've banged my head against a brick wall.

Secondly, I learned by experience to always assume the result value of the dialog can be undefined. There are 3 scenarios in which this occurs:

  1. Close the dialog without the optional result value.
  2. Close the dialog by clicking on the backdrop.
  3. Close the dialog by pressing the Escape key.

While there are ways to ensure that the result value will not be undefined, the problem lies deeper: there is no way to infer the type of the result value coming from the child component.

Can we do even better?

This missing link between the type of the dialog result value and the parent component poses a significant problem for type safety. While there has been a feature request to resolve this, it unfortunately did not receive enough votes. A solution will surely be hard-fought as you can not only define the result value by using it as an argument for dialog.close(), but you can also bind it to the mat-dialog-close attribute inside the template. Regardless, I hope this will someday be reconsidered.

Meanwhile, as I also posted on Github, an alternative approach can be found in this blog post. It involves creating an abstract class that your child component can extend from and a custom dialog service as follows:


@Directive()
export abstract class StronglyTypedDialog<DialogData, DialogResult> {
  constructor(
    @Inject(MAT_DIALOG_DATA) public data: DialogData,
    public dialogRef: MatDialogRef<
      StronglyTypedDialog<DialogData, DialogResult>,
      DialogResult
    >
  ) {}
}

@Injectable({ providedIn: 'root' })
export class DialogService {
  constructor(public dialog: MatDialog) {}

  open = <DialogData, DialogResult>(
    component: ComponentType<StronglyTypedDialog<DialogData, DialogResult>>,
    config?: MatDialogConfig<DialogData>
  ): MatDialogRef<
    StronglyTypedDialog<DialogData, DialogResult>,
    DialogResult
  > => this.dialog.open(component, config);
}

With this approach, we now need to define the result value type upfront. For example:


export class DialogComponent extends StronglyTypedDialog<DialogData, boolean> {
  okClick = () => this.dialogRef.close(true);

  /* The line below will throw a compile-time error
  because the type of the result value is declared as a boolean */
  cancelClick = () => this.dialogRef.close('cancel');
}

Our parent component can now infer the result value type based on the type of the child component.


export class CheckoutComponent {
  public constructor(private readonly dialog: DialogService) {}

  public open(): void {
    this.dialog.open(DialogComponent, {data: {something: 'hello'}})
      .afterClosed()
      /* The line below will throw a compile-time error
      because the type of the result value is declared as a boolean in the child component */
      .subscribe((result: string | undefined) => {
        // do something
      });
  }
}

However, one missing gap remains using the approach above: defining the result value in the template. Assume the following template for our DialogComponent:


<!-- The line below will not throw a compile-time error as the value for the mat-dialog-close attribute is not type-checked -->
<button [mat-dialog-close]="hello"></button>

Summary

We analyzed two approaches to increase type safety when opening and closing Material dialogs.

Our first approach makes use of the built-in generic type parameters, but lacks a link between the result value type and the parent component. In the second approach, we created our own link between the result value type and the parent component. But using the mat-dialog-close attribute in the template itself still poses a threat and the approach itself is more complex.

⇤ Return to blog overview