🎄

Vitest test-d.ts で複雑な型をテストする / TypeScript一人カレンダー

2024/12/20に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の14日目です。昨日は『実例 mustFind()』を紹介しました。

型定義にもテストを書く時代

ここまでの記事では、さまざまな自作型によって型安全性の向上や、業務の効率化、記述の簡素化、可読性の向上など、あらゆる改善を実践していく方法をいくつもご紹介しました。ユーティリティ型を実装する際、ts-essentialsのようなライブラリであれば、品質は基本的にOSSの信頼に委ねられます。しかし、自前で既存の型ユーティリティを拡張したり、新しい型定義を実装したりすると「それは本当に想定通りに機能しているのか?」という不安が生まれます。

かつては「型定義が正しく動いているか」を確かめるため、しらみつぶしにコードに挿入してコンパイルエラーを観察したり、エディタ上で型ヒントを睨んだりといった、半ば手動のデバッグを行うことが多くありました。ですが、実務で複雑な型定義を多用し、チームメンバーが増えるほど、こうした手動検証は非効率になっていきます。

Vitestで型定義をテストする

最近はJavaScript(TypeScript)コードをテストするツールとしてVitestが注目を集めていますが、これを「型定義のテスト」にも活用することが可能です。具体的には、.test-d.tsのような特殊な拡張子の付いたファイルを用いたテスト戦略が存在します。

現代のTypeScriptの複雑な型定義は、もはや関数そのものです。複数の型パラメータを引数のように使い、小さな型を何個も組み合わせて実装された入れ子構造になった巨大な型は、「入力(型パラメータ)」に対して特定の「出力(型)」を返す仕組みを持っています。JavaScriptの関数が正しく動作するかテストを書くのと同じ発想で、TypeScriptの型定義にもテストを書くことができます。

たとえば昨日紹介したmustFind()の戻り値の型が、想定通りにNarrowingによって限定されていることを検証してみましょう。昨日の記事ではテスト用関数を定義し、その関数に値を渡してコンパイルエラーにならないことを担保にしていましたが、本日は型を直接検証してみます。.test-d.tsという拡張子を伴ったファイルでは、Vitestの型検証用のAPIが使用できます。

昨日のmustFind()のテストと、本日の型テストを見比べてみてください。

import { describe, expectTypeOf, test } from "vitest";

import { mustFind } from "./must-find";

describe("mustFind()", () => {
  describe("found の型が正しい", () => {
    test("number[]", () => {
      const arr = [0, 1, 2];
      const found = mustFind(arr, (v) => v === 1);
      expectTypeOf<typeof found>().toEqualTypeOf<1>();
    });

    test("readonly number[]", () => {
      const arr = [0, 1, 2] satisfies readonly number[];
      const found = mustFind(arr, (v) => v === 1);
      expectTypeOf<typeof found>().toEqualTypeOf<1>();
    });

    test("string[]", () => {
      const arr = ["a", "b", "c"];
      const found = mustFind(arr, (v) => v === "b");
      expectTypeOf<typeof found>().toEqualTypeOf<"b">();
    });
  });

  describe("Discriminated union の Narrowing に対応している", () => {
    type A = { type: "a"; a: string };
    type B = { type: "b"; b: string };

    describe("read write", () => {
      const arr = [
        { type: "a", a: "A" },
        { type: "b", b: "B" },
      ] satisfies (A | B)[];

      test("case 0", () => {
        const found = mustFind(arr, (v): v is A => v.type === "a");
        expectTypeOf<typeof found>().toEqualTypeOf<A>();
      });

      test("case 1", () => {
        const found = mustFind(arr, (v) => v.type === "a");
        expectTypeOf<typeof found>().toEqualTypeOf<{
          type: "a";
          a: string;
          b?: undefined;
        }>();
      });

      test("case 2", () => {
        const found = mustFind(arr, (v): v is B => v.type === "b");
        expectTypeOf<typeof found>().toEqualTypeOf<B>();
      });
    });

    describe("readonly", () => {
      const arr = [
        { type: "a", a: "A" },
        { type: "b", b: "B" },
      ] satisfies readonly (A | B)[];

      test("case 0", () => {
        const found = mustFind(arr, (v): v is A => v.type === "a");
        expectTypeOf<typeof found>().toEqualTypeOf<A>();
      });

      test("case 1", () => {
        const found = mustFind(arr, (v) => v.type === "a");
        expectTypeOf<typeof found>().toEqualTypeOf<{
          type: "a";
          a: string;
          b?: undefined;
        }>();
      });

      test("case 2", () => {
        const found = mustFind(arr, (v): v is B => v.type === "b");
        expectTypeOf<typeof found>().toEqualTypeOf<B>();
      });
    });
  });
});

expectTypeOf<typeof found>().toEqualTypeOf<1>()という、今までのVitestのexpect().toEqual()とよく似た書き方で型の検証ができています。たとえば今挙げた例では、found変数の型 (typeof found)は1型であることを検証しています。ここで求まる型はnumber型になりそうにも見えますが、v === 1という比較によって自動的に1型であることが確定するのは現代のTypeScriptらしさですね。ここで、(v) => v % 2 === 0のように定数で比較していないときは、もちろんnumber型となります。

Narrowingが有効であることも、確実に検証できています。expectTypeOf<typeof found>().toEqualTypeOf<A>()のようにして、A型であることが求まっています。

一方で(v): v is A => v.type === "a"ではなく(v) => v.type === "a"としてv is AのType predicateを省略した場合、A型の推論にはなっていないことが検証できます。

test-d.tsnpx vitest --typecheckと実行したときに扱われます。このオプションが付いていない場合はスルーされるので、スクリプトに注意してください。そしてこうしたテストをVitestのタスクの一環として走らせれば、JavaScriptコードのユニットテストだけでなく、TypeScriptの型定義に対するユニットテストも同時に行えるため、複雑なユーティリティ型や、今回のmustFind()のように「正しくNarrowingは機能しているか」といった懸念にも確実に安心することができます。

「型パズル」と呼ばせないために

過去には、複雑な型定義は「型パズル」と揶揄されることがありました。ジェネリクスの複雑な組み合わせを読み解けず、複雑な型定義を嫌厭する開発者と、そういった抽象度の高い型を当然のごとく扱い生産性を上げる熟練した開発者との間に溝が生じやすかったのです。複雑な型定義は、触ると壊れそうだから触りたくない」「読めないし、拡張するのが怖い」という状態に陥りがちでした。

しかし、テストを書いて型定義を保護すれば、複雑な型定義も他の関数やモジュールと同じように、安全に保守できます。むしろ、型エラーを通じてロジックの誤りを早期発見し、チーム全体の生産性を向上させる「型駆動開発」に近い体験が実現できます。誰もが安心して高度な型を享受し、問題発生時には的確なエラーメッセージとテストで支えられる、そんな理想に一歩近づけるのです。

「複雑なユーティリティ型を実装した開発者が退職してしまい、もう消すことも作り変えることもできなくなってしまった」という悲痛な相談を筆者は受けたことがあります。どんなプログラムでも、テストがないとリファクタリングはとても困難を極めますが、JavaScriptの関数と同じようにテストを書けるようになったことで、かなりその敷居は下がったといえます。

今後、TypeScriptの達人の皆様におかれましては、複雑な型を定義する際はテストも一緒に書くようにすると、後続の開発者に幸せを届けられると思いますので、どうぞよろしくお願いいたします。

明日は『App Router 時代のエラーハンドリング』

本日は「Vitest d-test.ts」を紹介しました。明日は「App Router 時代のエラーハンドリング」を紹介します。それではまた。

Discussion