Marginalia

AngularアプリケーションでのAssertion in Production

これはAngularアプリケーションの開発においても表明 (Assertion)を取り入れてみようという実験である。簡単なサンプルはGitHubで公開しているので、興味があればそちらも見てもらえるといい。

Assertion in Browsers

Node.jsと違い、ブラウザ環境にはランタイム標準の assert 関数はないので、自前でどうにかする必要がある。その実装詳細はここではどうでもよいので素朴に実装する。関数は2種類あり、ひとつは unsafeAssertで、表明した事前条件が満たされなければ例外を投げる。もうひとつの safeAssert はコンソールログでアサーションエラーが表示されるのみで例外は投げない。

/**
 * This function is an assert function.
 * If the condition is false, it throws an error with the given message.
 */
function unsafeAssert(condition: boolean, message: string) {
  if (!condition) {
    const error = new Error(message);
    error.name = 'AssertionError';
    throw error;
  }
}

/**
 * This function is a safe version of the assert function.
 * It never throws an error, but instead logs the error message to the console.
 */
function safeAssert(condition: boolean, message: string) {
  console.assert(condition, message);
}

Toggle Assertion Strategy

上記の2つの関数を、実行モードにより切り替えたい。具体的には、ローカルでの開発時やステージング環境などでは例外を投げる unsafeAssert を使いたいが、プロダクション環境では safeAssert にしたい。

この戦略のトグルを実装するために、Angular v17.2で導入されたAngular CLIの define 機能を使ってみよう。 THROW_ASSERTION_ERRORというグローバル変数が true であるときにunsafeAssertを使うようにするセットアップ関数を実装する。

/**
 * This function sets up the global `assert` function.
 * If `THROW_ASSERTION_ERROR` is true, it sets the global `assert` function to `unsafeAssert`, which throws an error when the condition is false.
 * Otherwise, it sets the global `assert` function to `safeAssert`, which logs the error message to the console.
 */
export function setupGlobalAssert() {
  if (THROW_ASSERTION_ERROR) {
    window.assert = unsafeAssert;
  } else {
    window.assert = safeAssert;
  }
}

このコードが型チェックを通過できるように、 global.d.ts のようなファイルで型定義をしておくのも必要だ。

// src/global.d.ts
declare const THROW_ASSERTION_ERROR: boolean;
declare var assert: (condition: boolean, message: string) => void;

そしてsetupGlobalAssertmain.tsで呼び出せば準備完了だ。

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
import { setupGlobalAssert } from './lib/assert';

setupGlobalAssert();

bootstrapApplication(AppComponent, appConfig).catch((err) =>
  console.error(err)
);

あとは、angular.jsonを開いてビルドオプションのデフォルト設定と development のときの切り替えをそれぞれ行うといい。

      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:application",
          "options": {
            ...
            "define": {
              "THROW_ASSERTION_ERROR": "false"
            }
          },
          "configurations": {
            "production": {...},
            "development": {
              ...
              "define": {
                "THROW_ASSERTION_ERROR": "true"
              }
            }
          },
          "defaultConfiguration": "production"
        },

こうすることで、コード中のTHROW_ASSERTION_ERRORはビルド時にfalsetrueに置換される。条件分岐のどちらを通るかがビルドのタイミングで決定できるため、Angular CLIはTree Shakingによって使わない方のassert関数をデッドコードとして削除できる。

Assertion in Production

このように準備を整えると、次のように(この例の条件は適当だが)コンポーネントで気軽に表明ができる。もちろんコンポーネントじゃなくてもアプリケーションのどこででもできる。

import { Component } from '@angular/core';

@Component({
  ...
})
export class AppComponent {
  title = 'ng-assertion';

  ngOnInit() {
    assert(this.title === 'ng-assertion', 'Title is not ng-assertion');
  }
}

assert関数が十分に軽量であれば、テストコードではなくアプリケーションコードの側にこのような表明を書いていくことで、ユーザーへの悪影響を最小限にして契約による設計を取り入れていけるのではなかろうか。