Writing a Generic Type-Safe ng-bootstrap NgbModal Launcher

For an Angular project for one of our clients, I’ve recently started using ng-bootstrap to implement standard modal dialogs in a ModalService. This service has methods to launch confirmation dialogs, input dialogs, message dialogs, etc; you can see a simplified version of this service on StackBlitz.

An addition to the reusable standard dialogs, we also needed to support custom one-off dialogs, and we wanted to use the same general approach, without adding a bunch of duplicate code. Most of the implementation was straight-forward, but adding type safety to the generic launcher was more interesting. Read below to see how interfaces from TypeScript and Angular made it easy.

Initial App

I’ve created a simplified version of the project’s ModalService on GitHub to see the technique.

modal.service.ts

import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Observable, from, of } from 'rxjs';
import { catchError } from 'rxjs/operators';

import {
  ConfirmDialogComponent
} from './confirm-dialog/confirm-dialog.component';
import {
  InputDialogComponent
} from './input-dialog/input-dialog.component';
import {
  MessageDialogComponent
} from './message-dialog/message-dialog.component';

@Injectable({
  providedIn: 'root'
})
export class ModalService {

  constructor(private ngbModal: NgbModal) { }

  confirm(
    prompt = 'Really?', title = 'Confirm'
  ): Observable<boolean> {
    const modal = this.ngbModal.open(
      ConfirmDialogComponent, { backdrop: 'static' });

    modal.componentInstance.prompt = prompt;
    modal.componentInstance.title = title;

    return from(modal.result).pipe(
      catchError(error => {
        console.warn(error);
        return of(undefined);
      })
    );
  }

  // similar methods for other standard dialogs...
}

This service method calls out to a specific component for the display of the modal on line 27:

confirm-dialog.component

import {
  ChangeDetectionStrategy,
  Component
} from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';

@Component({
  selector: 'app-confirm-dialog',
  template: `
<div>
  <div class="modal-header">
    <h4 class="modal-title">{{title}}</h4>
  </div>
  <div class="modal-body">
    <p>{{prompt}}</p>
  </div>
  <div class="modal-footer">
    <button type="button"
      class="btn btn-outline-dark"
      (click)="activeModal.close(false)">Cancel</button>
    <button type="button"
      class="btn btn-outline-dark"
      (click)="activeModal.close(true)">OK</button>
  </div>
</div>
`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmDialogComponent {
  title: string;
  prompt: string;

  constructor(public activeModal: NgbActiveModal) {
  }
}

Finally, here are the relevant parts of the AppComponent, from which this service method is used, and onto which the result is displayed:

app.component.html

<div>
  <button type="button" class="btn btn-outline-dark" (click)="openConfirm()">Open Confirm Modal</button>
  Confirmation result: {{confirmedResult}}
</div>

app.component.ts

openConfirm() {
  this.modalService.confirm(
    'Are you sure?'
  ).pipe(
    take(1) // take() manages unsubscription for us
  ).subscribe(result => {
      console.log({ confirmedResult: result });
      this.confirmedResult = result;
    });
}

As you can see, the component has properties of title and message, which are set in the service method on lines 29 and 30. These properties serve the purpose that @Input() fields usually perform for Angular components, but these components are created in an unusual way, so @Input() fields are not useful for these components.

When the user closes the dialog, the result (true or false) is logged to the console and displayed next to the button.

With this pattern set, how would we create a generic function that could be used with any component? We would want a way to set the inputs, but to do it in a type-safe way that is knowledgeable about the components inputs. Not only that, we also would want to be able to specify the type of the result that the dialog produces.

Custom Component

Let’s say we wanted to be able to display the following component without knowing the details of it in our service:

custom-dialog.component.ts

import {
  ChangeDetectionStrategy,
  Component
} from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';

@Component({
  selector: 'app-custom-dialog',
  template: `
<div>
  <div class="modal-header">
    <h4 class="modal-title">Ice Cream Chooser</h4>
  </div>
  <div class="modal-body">
    <p>What flavor do you want?</p>
    <div>
      <p *ngFor="let flavor of flavors">
        <label><input [formControl]="choice"
          [value]="flavor"
          type="radio">{{flavor}}</label>
      </p>
    </div>

  </div>
  <div class="modal-footer">
    <button type="button"
      class="btn btn-outline-dark"
      (click)="activeModal.close()">Cancel</button>
    <button type="button"
      class="btn btn-outline-dark"
      [class.disabled]="choice.invalid"
      [disabled]="choice.invalid"
      (click)="activeModal.close(choice.value)">OK</button>
  </div>
</div>
`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomDialogComponent {
  flavors: string[];
  choice = new FormControl('', Validators.required);

  constructor(public activeModal: NgbActiveModal) {
  }
}

Non-Type-Safe Approach

Here’s an initial approach:

modal.service.ts

  custom(
    content: any,
    config?: { [index: string]: any; },
    options?: NgbModalOptions
  ): Observable<any> {
    // we use a static backdrop by default,
    // but allow the user to set anything in the options
    const modal = this.ngbModal.open(
      content,
      { backdrop: 'static', ...options }
    );

    // copy the config values (if any) into the component
    Object.assign(modal.componentInstance, config);

    return from(modal.result).pipe(
      catchError(error => {
        console.warn(error);
        return of(undefined);
      })
    );
  }

app.component.ts

  openCustomDialog() {
    this.modalService.custom(
      CustomDialogComponent,
      { flavors: ['Vanilla', 'Chocolate', 'Rocky Road'] }
    ).pipe(
        take(1) // take() manages unsubscription for us
      ).subscribe(result => {
        console.log({ customResult: result });
        this.customResult = result;
      });
  }

While this certainly works, it isn’t type-safe; The Object.assign(...) call on line 14 of the modal.service.ts snipped applies all of the values in the config input to the component, but we have no confidence that the values we pass in config are valid in name or type for CustomDialogComponent. Additionally, the type of result produced from the modal is merely any.

Type Safety on the Return Value

We can define the type of the return type by declaring a parameter type for the return value:

modal.service.ts

  custom<R>(
    content: any,
    config?: { [index: string]: any; },
    options?: NgbModalOptions
  ): Observable<R> {
    ...
  }

app.component.ts

  openCustomDialog() {
    this.modalService.custom<string>(
      CustomDialogComponent,
      { flavors: ['Vanilla', 'Chocolate', 'Rocky Road'] }
    ).pipe(
        take(1) // take() manages unsubscription for us
      ).subscribe(result => {
        console.log({ customResult: result });
        this.customResult = result;
      });
  }

TypeScript can now infer that the type of result is string, but the inputs aren’t right.

What we want is to be able to pass CustomDialogComponent into the function (so that NgbModal#open can use it as a constructor), and also restrict the property names and types of config to match the property names and types of CustomDialogComponent.

Type-Safe config

We can solve the second part through TypeScript’s Partial. A Partial of T is an interface which contains all of the properties of T, but with them converted to optional. That’s perfect for us; we can use a Partial of CustomDialogComponent as the second argument, and that provides us with type safety. If anyone tries to pass in a value where the name or the type doesn’t match the name and type of a property of CustomDialogComponent, the compiler will complain.

Let’s try that:

modal.service.ts

  custom<T, R>(
    content: any,
    config?: Partial<T>,
    options?: NgbModalOptions
  ): Observable<R> {
    ...
  }

app.component.ts

  openCustomDialog() {
    this.modalService.custom<CustomDialogComponent, string>(
      CustomDialogComponent,
      { flavors: ['Vanilla', 'Chocolate', 'Rocky Road'] }
    ).pipe(
        take(1) // take() manages unsubscription for us
      ).subscribe(result => {
        console.log({ customResult: result });
        this.customResult = result;
      });
  }

This is great, but there’s no connection between the first argument and the second; they should both be based on the same component. My first thought was to look at how ng-bootstrap defined the type of the arguments for NgbModal#open(), but the first argument is defined as any because the library function is more general-purpose than we need (it can take content as a TemplateRef in addition to components, a feature we don’t need).

My second thought was to define the first argument like this: content: T. Unfortunately, this doesn’t work. This syntax would require us to pass in an instance of CustomDialogComponent, not the type itself.

Type-Safe content

Fortunately, Angular helps us here by providing the Type type. We can define the argument as content: Type, and this works beautifully.

Interestingly, if you look at the API documentation for Type, you’ll see that Type extends Function, because it’s really the constructor function, not a class type. This might be surprising for someone coming from a language like Java, but it’s a reminder that JavaScript and TypeScript use prototypal inheritance, not classical inheritance.

So, here’s the final code for our custom launcher (the caller didn’t change):

modal.service.ts

  custom<T, R>(
    content: any,
    config?: Partial<T>,
    options?: NgbModalOptions
  ): Observable<R> {
    ...
  }

Removing Duplication

Finally, with the generic launcher in place, we don’t need a lot of the logic that we had in each of the standard launcher methods. We can remove a lot of code by reusing our new launcher. Our confirm method was 16 lines before; now it’s just 6.

modal.service.ts

  confirm(
    prompt = 'Really?', title = 'Confirm'
  ): Observable<boolean> {
    return this.custom<ConfirmDialogComponent, boolean>(
      ConfirmDialogComponent, { title, prompt });
  }

Conclusion

Using Type and Partial, we were able to make a generic function that can take any component type and a type-safe configuration object to initialize the instance.

Generics aren’t obvious or trivial, but they are extremely powerful when you know how to wield them.

Tagged as: ,