🩺

型レベルユニットテストのススメ

2021/09/05に公開

準備

export type Extends<E, A> = A extends E ? true : false;
export type NotExtends<E, A> = A extends E ? false : true;
export type Exact<A, B> = Extends<A, B> extends true
  ? Extends<B, A> extends true
    ? true
    : false
  : false;
export type Match<E, A extends E> = Exact<E, Pick<A, keyof E>>;
export type Never<T> = T extends never ? true : false;

export type Assert<T extends true> = T;

テスト


type ComplexType = { a: number; b?: number };
type _ = Assert<Match<{ a: number }, ComplexType>>; // OK
type _ = Assert<Match<{ a: number; b: number }, ComplexType>>; // Error
type _ = Assert<Match<{ b?: number }, ComplexType>>; // OK
type _ = Assert<Match<{ c: string[] }, ComplexType>>; // Error

よく使うのはMatchです。左に 期待する部分型 、右に 実際の型 を書きます。例えば*.test.tsに上の型を使ったテストをかいておいて、npx tsc && npx jestを実行すると、jestのユニットテストに加えて、TSの型チェックが入ります。

注意点として、vscodeはプロジェクトのtscofig.jsonに指定したファイル以外はvscodeのデフォルト(?)設定でチェックをかけるので、気づかないうちにStrictNullCheckがOffになってたりして表示上、謎の挙動をしてテストが崩壊することがあります。私はvscodeが参照するためのプロジェクト共通のtsconfigと、globを変えただけのビルド用のtsconfig.buildを使い分けてます。

蛇足

これだけだと味気ないので語ります。

fire-fuseという型パズルライブラリを作っているのですが、型が複雑すぎて型に対してユニットテスト的なものをしたくなりました。tsdという型テストライブラリがあるのですが、これは設定が結構複雑な上、JSオブジェクトが実際にTSの型と一致しているかを調べるので、TSの型が期待した型になっているかどうか調べたいという私のケースにはマッチしませんでした。

firefuseで使っている型(参考)
export type ConstrainedData<
  T extends DocumentData,
  C extends readonly firestore.QueryConstraint[],
  Mem extends Memory<T> = {
    rangeField: StrKeyof<T>;
    eqField: never;
    prevNot: false;
    prevArrcon: false;
    prevOr: false;
    prevOrderBy: false;
  }
> = C extends []
  ? T
  : C extends readonly [infer H, ...infer Rest]
  ? Rest extends readonly firestore.QueryConstraint[]
    ? H extends WhereConstraint<infer U, infer K, infer OP, infer V>
      ? T extends U
        ? OP extends GreaterOrLesserOp
          ? K extends Mem["rangeField"]
            ? ConstrainedData<Defined<T, K>, Rest, Mem & { rangeField: K }>
            : never
          : OP extends "=="
          ? ConstrainedData<
              T & { [L in K]-?: V },
              Rest,
              OR<Mem, { eqField: K }>
            >
          : OP extends "!="
          ? Mem["prevNot"] extends true
            ? never
            : K extends Mem["rangeField"]
            ? ConstrainedData<
                T & { [L in K]-?: Exclude<T[L], V> },
                Rest,
                OverWrite<Mem, { prevNot: true }> & { rangeField: K }
              >
            : never
          : OP extends "array-contains"
          ? Mem["prevArrcon"] extends true
            ? never
            : ConstrainedData<
                Defined<T, K>,
                Rest,
                OverWrite<Mem, { prevArrcon: true }>
              >
          : OP extends "array-contains-any"
          ? Mem["prevArrcon"] extends true
            ? never
            : Mem["prevOr"] extends true
            ? never
            : ConstrainedData<
                Defined<T, K>,
                Rest,
                OverWrite<Mem, { prevArrcon: true; prevOr: true }>
              >
          : OP extends "in"
          ? Mem["prevOr"] extends true
            ? never
            : V extends readonly T[K][]
            ? ConstrainedData<
                T & { [L in K]-?: V[number] },
                Rest,
                OR<OverWrite<Mem, { prevOr: true }>, { eqField: K }>
              >
            : never
          : OP extends "not-in"
          ? Mem["prevOr"] extends true
            ? never
            : Mem["prevNot"] extends true
            ? never
            : V extends readonly T[K][]
            ? ConstrainedData<
                T & { [L in K]-?: Exclude<T[L], V[number] | undefined> },
                Rest,
                OverWrite<Mem, { prevOr: true; prevNot: true }>
              >
            : never
          : never
        : never
      : H extends OrderByConstraint<infer K>
      ? Mem["prevOrderBy"] extends true
        ? ConstrainedData<Defined<T, K>, Rest, Mem>
        : K extends Mem["rangeField"]
        ? ConstrainedData<
            Defined<T, K>,
            Rest,
            OverWrite<Mem, { prevOrderBy: true }>
          >
        : never
      : H extends OtherConstraints
      ? ConstrainedData<T, Rest, Mem>
      : never
    : never
  : never;

使用例

こんな感じでjestのユニットテストみたいにして書いています(実際にランタイムでテストは走らないですが)。

test("field of > exists", () => {
  const cs = [where("population", ">=", 1000)] as const;
  type T = CD<City, typeof cs>;

  type _ = Assert<Match<{ population: number }, T>>;
});

test("field of == exists", () => {
  const cs = [where("capital", "==", true as const)] as const;
  type T = CD<City, typeof cs>;
  type _ = Assert<Match<{ capital: true }, T>>;
});

Discussion