skip to content

🦺Adding Type Safety to Angular Material Dialog

Let's make mat-dialog check for the correct data types.

Problem🤔

In Material Dialog we can pass three generic types when opening the dialog, Component Type <T>, Data Type <D> and Return Type <R>, but these types are passed by the component which is opening the dialog, and not dialog component itself, this process is effectively making the caller component guess the required types for opening the dialog component. This approach requires the dev to have strong knowledge, about the dialog component that they are trying to use.

Solution💡

We can mitigate this issue by creating a custom Dialog Wrapper and Dialog Service.

Wrapper

Dialog Wrapper is going to be an abstract class, with two properties dialogRef and matDialogData

import { MatDialogRef } from '@angular/material/dialog';
 
export abstract class DialogWrapper<
  T = any,
  Data = any,
  Return = any,
> {
  abstract dialogRef: MatDialogRef<T, Return>;
  abstract matDialogData: Data;
  constructor() {}
}
import { MatDialogRef } from '@angular/material/dialog';
 
export abstract class DialogWrapper<
  T = any,
  Data = any,
  Return = any,
> {
  abstract dialogRef: MatDialogRef<T, Return>;
  abstract matDialogData: Data;
  constructor() {}
}

Dialog

Now Dialog Component can inherit from Dialog Wrapper.

export class DialogComponent extends DialogWrapper {
  constructor(
    public dialogRef: MatDialogRef<DialogComponent, '🍕'>,
    @Inject(MAT_DIALOG_DATA) public matDialogData: ['🥖', '🧀', '🍅']
  ) {
    super();
  }
 
  close() {
    this.dialogRef.close('🍕');
  }
}
export class DialogComponent extends DialogWrapper {
  constructor(
    public dialogRef: MatDialogRef<DialogComponent, '🍕'>,
    @Inject(MAT_DIALOG_DATA) public matDialogData: ['🥖', '🧀', '🍅']
  ) {
    super();
  }
 
  close() {
    this.dialogRef.close('🍕');
  }
}

Service

In Dialog Service we are going to do some type magic🪄

import { ComponentType } from '@angular/cdk/portal';
import { Injectable } from '@angular/core';
import {
  MatDialog,
  MatDialogConfig,
  MatDialogRef,
} from '@angular/material/dialog';
import { DialogWrapper } from './dialog-wrapper';
 
@Injectable({
  providedIn: 'root',
})
export class DialogService {
  private config: MatDialogConfig = {
    maxHeight: '90%',
  };
  constructor(private dialog: MatDialog) {}
  open<T extends DialogWrapper>(
    component: ComponentType<T>,
    data: dialogData<T>
  ) {
    return this.dialog.open<T, dialogData<T>, dialogReturn<T>>(
      component,
      {
        ...this.config,
        data,
      }
    );
  }
}
 
type getRefReturn<T, K extends keyof T> = T[K] extends MatDialogRef<
  infer refT,
  infer refR
>
  ? refR
  : never;
type filterRefProp<T> = {
  [K in keyof T]: T[K] extends MatDialogRef<any, any> ? K : never;
};
 
type propName<T> = filterRefProp<T>[keyof filterRefProp<T>];
 
type dialogReturn<T> = getRefReturn<T, propName<T>>;
 
type dialogData<T extends DialogWrapper<any, any, any>> =
  'matDialogData' extends keyof T ? T['matDialogData'] : never;
import { ComponentType } from '@angular/cdk/portal';
import { Injectable } from '@angular/core';
import {
  MatDialog,
  MatDialogConfig,
  MatDialogRef,
} from '@angular/material/dialog';
import { DialogWrapper } from './dialog-wrapper';
 
@Injectable({
  providedIn: 'root',
})
export class DialogService {
  private config: MatDialogConfig = {
    maxHeight: '90%',
  };
  constructor(private dialog: MatDialog) {}
  open<T extends DialogWrapper>(
    component: ComponentType<T>,
    data: dialogData<T>
  ) {
    return this.dialog.open<T, dialogData<T>, dialogReturn<T>>(
      component,
      {
        ...this.config,
        data,
      }
    );
  }
}
 
type getRefReturn<T, K extends keyof T> = T[K] extends MatDialogRef<
  infer refT,
  infer refR
>
  ? refR
  : never;
type filterRefProp<T> = {
  [K in keyof T]: T[K] extends MatDialogRef<any, any> ? K : never;
};
 
type propName<T> = filterRefProp<T>[keyof filterRefProp<T>];
 
type dialogReturn<T> = getRefReturn<T, propName<T>>;
 
type dialogData<T extends DialogWrapper<any, any, any>> =
  'matDialogData' extends keyof T ? T['matDialogData'] : never;

Dialog Service has a generic open() method which takes 2 arguments component and data same as MatDialog.open(), but because it only accepts component of Type DialogWrapper we can extract the Return and Data type of the passed component.

To extract Return type we can use filterRefProp type to find key of the property with type MatDialogRef<any,any> and the use that Key to infer the Return type by using getRefReturn<T,K>

To extract Data type we can simply get the type of property matDialogData

Using the service

Finally now, we can use our Dialog service to open the dialog from any component, and get type inference.

export class SomeComponent {
  constructor(private dialogService: DialogService) {}
 
  openDialog() {
    const dialogRef = this.dialogService.open(DialogComponent, [
      '🥖',
      '🧀',
      '🍅',
    ]);
    dialogRef.afterClosed().subscribe((result) => {
      console.log(result); // '🍕'
    });
  }
}
export class SomeComponent {
  constructor(private dialogService: DialogService) {}
 
  openDialog() {
    const dialogRef = this.dialogService.open(DialogComponent, [
      '🥖',
      '🧀',
      '🍅',
    ]);
    dialogRef.afterClosed().subscribe((result) => {
      console.log(result); // '🍕'
    });
  }
}

Bonus🎁

While using Dialog Service we can also set some sort of global config for the dialog. For example the global service above makes the max height of all the dialogs to be 90%

Missing piece🧩

The current proposed solution is dependent on Dialog Wrapper to extract the Data type, but if we can find the property in dialog component that has @Inject(MAT_DIALOG_DATA) Decorator we can remove the wrapper class all together. Let me know if there is a way to do it? 😄

Update

Typescript Roadmap for 5.0 has a proposal to Implement ES Decorator, so might be worth revisiting the solution to use the decorators