🎄

実例 assertMatchedType() / TypeScript一人カレンダー

2022/12/25に公開

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

TypeScriptの集大成

TypeScriptを採用する最大の目的は、Web開発において型による検証をもたらし不整合な実装にいち早く気付けるようになることです。開発というのは、その対象のプログラムが大規模になるほど、開発関係者が増えるほど、不確実さが増しエントロピー増大の法則に従うかのように破綻に進んでいきます。

そこで我々は、混沌とした状況を少しでも防ぐべくany型の代わりにunknown型を使い、asの代わりにAssertion Functionsを使い、TypeScriptの進化によって増えていく機能を余すところなく引き出して信頼できる開発環境を整えていくようになります。

アドベントカレンダー最終日である本日は、1日目から24日目までに伝えたことを全て活用し、TypeScriptによってどこまで信頼できる環境を構築できるのかという集大成について紹介します。

不確実な環境での安心の獲得

不整合は常に起こりうる

カレンダー内でたびたび触れましたが、筆者の参加した案件はバックエンド構成がとても複雑なものでした。

バックエンドのデータ取得のレイヤーだけみても、Firestore, PostgreSQL, GraphQL、ヘッドレスCMSと多岐に渡り、Firestoreはデータをうっかり削除できてしまう、ヘッドレスCMSは他社の非開発職の担当者によって想定外のデータを入力されてしまう…など、とにかく「いつどんな不整合が起こるかわからない」という状況での開発でした。

こういった状況では、混沌のまま開発を継続するのは精神的に健全ではありません。いかに整合されていると確信を持って開発を継続できるかが肝です。不整合が発生した場合にそれが放置されず、なんとなくで動作せず、確実に信頼できる状況に改善されるまではプログラムを動作させないという実装が求められました。

防御的プログラミングと契約による設計

このような状況で有用な考え方が、防御的プログラミングと契約による設計です。契約による設計の英訳"Design by Contract"を短縮したDbCと表現されることもあるため、本稿では以下DbCとします。

DbCについて、ひとつは5年前の資料ですが今も色褪せないものとして@t_wadaさんの記事がとても参考になります。

紹介されている言語はPHPではありますが、PHPの言語の細部を把握しておらずとも他言語利用者としても読みやすく、そして他言語であっても考え方自体はとても参考にしやすい内容です。

筆者が参加した案件でも、TypeScriptによる型付けを丁寧に行うのは大前提として、さらにDbCの考え方を全面的に導入しています。

不整合はなぜ起こったか

不整合の発生は、即座に特定し、即座に修正せねばなりません。その際に、漫然とエラーログをみて、漫然とconsole.log()を書いて「どこのせいだろうなぁ」と考えているようでは遅れをとってしまいます。エラーログを確認して、その該当するファイル名や行番号を見ただけで「何によってこうなった」と即座に特定できる状況が求められます。

筆者が身につけたWebアプリケーション設計の哲学から、エラーは大きくわけて4つに分類できると考えています。

  • クライアント・サイド / エンドユーザーによるもの
  • クライアント・サイド / 開発者自身の実装誤りによるもの
  • サーバー・サイド / 開発者自身の実装誤りによるもの
  • サーバー・サイド / 外部サービス・外部データベースによるもの

ここで開発者自身というのは、自分のことではなく、開発チームがあるのであればそのチーム全体を指します。対して、エンドユーザーと外部サービス・外部データベースは開発チームのメンバーの管理が及ばないところを指します。開発チームの自分たちで保守するデータベースについてはどちらに分類してもよいですが、一旦外部のものとして扱いましょう。

不整合が発生したとき、言い換えるとコンソールにエラーログが表示されたとき、それが何によるものなのかはまずこの4つに分類できます。

エンドユーザーの誤りとは、たとえばメールアドレスを入力するフォームに@が含まれていないだったり、名前が空欄のままだったりというものです。いわゆるバリデーションエラーと呼ばれる部類ですね。これはもう日常茶飯事です。エンドユーザーはこちらの期待している通りのデータを入れてくれないという前提で、実装よりはむしろGUIやデザインの方面でエラーの発生を軽減する工夫が必要になります。本稿でもこれ以上は述べません。

内部起因か外部起因か

残る開発者自身の誤りと外部の不整合は、どちらも技術寄りの関心です。ここで原因が「内部」なのか「外部」なのかを即座に特定する必要があります。たとえばGraphQLをひとつ取り上げても、外部サーバーで動作するGraphQL Resolverの実装誤りなのか、こちらが記述したgqlクエリに記述ミスがあるからなのかを突き止める必要があります。

ヘッドレスCMSを取り上げても、内部の実装は問題ないはずだし開発環境でのテストもグリーンなのに、外部で担当者が誤って本番環境の設定を削除してしまった、なんてこともあるかもしれません。

このように「なんでかな?」と頭を抱えている時間を限りなく短くして、実装誤りなのか外部でのトラブルなのかを即座に追求するためには、DbCが欠かせないものとなります。

unknown型を扱った信頼できる仕組みづくり

Assertion Functions

TypeScriptのAssertion Functionsは、DbCを採用する上で非常に有益です。Assertion Functionsはアドベントカレンダー内では次の記事でも紹介しています。

今回はそのAssertion Functionsの中でも、特に筆者が有するTypeScriptの知識を全て注いで実装したassertMatchedType()について紹介します。

unknown型を採用する欠点

以前『実例 RecursivePartial<T>』でも述べたようにGraphQLの型定義が盤石とはいえず、undefined発生のリスクと隣合わせの状態で開発をしていたことがありました。そして今回も前節の例で触れたように、ヘッドレスCMSの設定はけっこう気軽に書き換えができてしまい、これもundefinedや予期しない型の値が混入するリスクに直結しています。ヘッドレスCMSの場合は、そのサービスが提供するSDKの型定義ファイルに、堂々とレスポンス形式はanyと書かれてしまっていることも多いです。

そこで筆者の案件では、とにかく外部の情報は一切信頼しない、性悪説100に振り切って実装する方針にしました。こんなときに役に立つ型がunknown型です。

https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown

unknownNarrowingと組み合わせない限り、開発ではまるで使えず、何をやろうとしてもエラーになるのです。ですが、それでよいのです。信頼性を検証する前にうっかり何かできてしまうとまずいためです。

unknown型を信頼できる型として扱うためには、それがオブジェクトである、それにはsomethingというプロパティがある、そのsomethingプロパティの中の値は文字列である…と枝葉のひとつひとつまで徹底的にランタイムで検証し、型をひとつひとつ明らかにしていく必要があります。これは結果からみると正しいですが、やはり明らかに労力が要ります。なにより実装量が多すぎて、可読性も大幅に低下するという問題がありました。

開発のモチベーション

このままではunknow型にしたい気持ちは山々だが、やっぱりany型でいいや…と諦めてしまうことにも同情してしまいます。そこで「カジュアルにunknownを扱えて、かつ可読性を高く保つ」という仕組みを模索するところから始まりました。

任意のオブジェクトに必要なプロパティがすべて備わっているかどうか、という検証用関数自体はすぐに作れそうです。例えば次のようなコードを想定します。実装のまだないisMatchedType()関数では、第一引数に検証したいオブジェクト、第二引数に備えている必要があるプロパティ名の配列を渡せるものと仮定します。

type Obj = {
  a: string;
  b: number;
  c: boolean;
  d?: string;
};

const obj: Obj = {
  a: "A",
  b: 1,
  c: true,
};

// 例1
isMatchedType(obj, ["a", "b", "c"]); // true

// 例2
isMatchedType({ a: "A" }, ["a", "b", "c"]); // false

例1は引数objのプロパティにa, b, cが含まれているため、第二引数の["a", "b", "c"]を満たしtrueとなります。例2は、第二引数に対して第一引数側のオブジェクトのプロパティが足りておらずfalseとなります。こういった関数を実装することはできそうです。

ところがTypeScriptとして考えると、シグネチャの型定義に悩むところです。isMatchedType(v: any, props: string[])だとちょっと弱すぎます。propsstring[]型のせいでゆるくなってしまいます。例えば次のような状況で困ります。

type Obj = {
  somethingLongPropertyName: string;
  b: number;
  c: boolean;
  d?: string;
};

const obj: Obj = {
  somethingLongPropertyName: "A",
  b: 1,
  c: true,
};

isMatchedType(obj, ["a", "b", "c"]); // false

リファクタリングを実施してasomethingLongPropertyNameに変更したとしましょう。isMatchedType()の第二引数も["somethingLongPropertyName", "b", "c"]に変更しないといけませんが、string[]型ですと["a", "b", "c"]が満たしてしまっているため特にエラーとなりません。

ということはisMatchedType<T>(v: unknown, props: /* Tから導出 */)のように関数isMatchedType()に型パラメータTを要求するようにすれば、propsの引数型アノテーションが求められるようになります。この仕組みを考えましょう。

assertMatchedType()の実装

型のユニットテスト

ここから複雑な仕組みが出てきます。どんなプログラミング言語であれ、プログラムが複雑になるのであれば自動テストを書いてその処理の正当性を保証したいものです。TypeScriptの型システムプログラミングもそれは例外ではありません。そこで、実装した型が期待通りであるかは次のEquals<X, Y>を使って検証します。

type Equals<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;

この仕組みは筆者が発明したものではなく、mattmccutchen氏によって提案されていた内容です。

https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650

なお、#27024の起票者kgtkr氏は、起票前にQiitaにて日本語の記事を提供くださっています。筆者が最初に知ったのはこちらが先でして、敬意を表して紹介いたします。

https://qiita.com/kgtkr/items/2a8290d1b1314063a524

登り方を検討する

次のようにany, unknownやOptionalなプロパティが絡んだObj型があるとします。

type Obj = {
  a: string;
  b: number;
  c: any;
  d: unknown;
  e: undefined;
  f: null;
  aOpt?: string;
  bOpt?: number;
  cOpt?: any;
  dOpt?: unknown;
  eOpt?: undefined;
  fOpt?: null;
};

ここから、必須プロパティであるa, b, c, d, eのみを抽出し、Optionalなプロパティは型に関わらず除外したいとします。次のようなオブジェクト型を定義し、Equals<Something<Obj>, Expected>true型 (Boolean Literal Types) となるようなSomething<T>を検討します。

type Expected = {
  a: string;
  b: number;
  c: any;
  d: unknown;
  e: null;
};

type Obj = {
  a: string;
  b: number;
  c: any;
  d: unknown;
  e: null;
  f: undefined;
  aOpt?: string;
  bOpt?: number;
  cOpt?: any;
  dOpt?: unknown;
  eOpt?: null;
  fOpt?: undefined;
};

type Something<T> = T; // 未実装

const result: Equals<Something<Obj>, Expected> = true;

アプローチとして正解はないのでしょうが、筆者が思いついたのは「最終的にnever型を除去する」というものでした。この案は欠点として、never型を維持したいという場合に不適格なのですが、業務上のこのユースケースにおいてわざわざnever型を残したいという想定がないため、このユースケースにおいてはnever型を意図して維持できない点は無視しました。同様にundefinedしか格納できないプロパティという定義も業務上では不要のため、今回は考慮から省いています。

ということでkeyof ObjからOpt接尾辞がついたプロパティを除去することを短期目的としましょう。次のコードがエラーとならないRemoveNeverProperties<T>を実装します。

type RemoveNeverProperties<T> = T; // 未実装

const result010: Equals<
  keyof Obj,
  | "a"
  | "b"
  | "c"
  | "d"
  | "e"
  | "f"
  | "aOpt"
  | "bOpt"
  | "cOpt"
  | "dOpt"
  | "eOpt"
  | "fOpt"
> = true;

const result020: Equals<
  keyof RemoveNeverProperties<Obj>,
  "a" | "b" | "c" | "d" | "e"
> = true; // 未実装のためエラー

RemoveNeverProperties<T>

OptionalAsNever<T>RemoveNeverProperties<T>を実装してみました。

type OptionalAsNever<T> = {
  [P in keyof T]-?: T[P] extends NonNullable<T[P]> ? T[P] : never;
};

type RemoveNeverProperties<T> = Pick<
  T,
  {
    [P in keyof T]: [T[P]] extends [never] ? never : P;
  }[keyof T]
>;

const result030: Equals<OptionalAsNever<Obj>, {
  a: string;
  b: number;
  c: any;
  d: never;
  e: never;
  f: never;
  aOpt: never;
  bOpt: never;
  cOpt: any;
  dOpt: never;
  eOpt: never;
  fOpt: never;
}> = true;

流れとしては、まずOptionalなプロパティを一斉にnever型に変換し、その結果を一斉に取り除くというアプローチにしています。OptionalAsNever<T>の挙動は欠点としてOptional如何に関わらずanyを扱えない、そして必須のunknownnullを扱えないという問題があります。d, e, f, cOptを見ると確認できます。そのため先にこういった型は逃がす必要があります。

Escape<T>

Escape<T>を実装しましょう。ここではany, unknown, nullのひとつひとつについてEquals<X, Y>を使って0型に置き換えていきます。0は逃したことを示したいだけなので他のどんな型でもいいですが、neverなどの意味を持つ型にしてはいけません。

Escape<T>の中でネストのConditional Typesを構築することはもちろんできますが、今回は解説も目的としているので個別に書いています。

type AnyAs0<T> = {
  [P in keyof T]: Equals<T[P], any> extends true ? 0 : T[P];
};

type UnknownAs0<T> = {
  [P in keyof T]: Equals<T[P], unknown> extends true ? 0 : T[P];
};

type NullAs0<T> = {
  [P in keyof T]: Equals<T[P], null> extends true ? 0 : T[P];
};

type Escape<T> = NullAs0<UnknownAs0<AnyAs0<T>>>;

const result020: Equals<
  keyof RemoveNeverProperties<OptionalAsNever<Escape<Obj>>>,
  "a" | "b" | "c" | "d" | "e"
> = true; // OK

const result040: Equals<OptionalAsNever<Escape<Obj>>, {
  a: string;
  b: number;
  c: 0;
  d: 0;
  e: 0;
  f: never;
  aOpt: never;
  bOpt: never;
  cOpt: never;
  dOpt: never;
  eOpt: never;
  fOpt: never;
}> = true;

おめでとうございます!keyof RemoveNeverProperties<OptionalAsNever<Escape<Obj>>>が期待の値"a" | "b" | "c" | "d" | "e"となりました。ここで当初の目的を思い出してみます。

isMatchedType<Obj>(obj, ["a", "b", "c"]);

このようにisMatchedType<T>()の第二引数を導出したいのでした。ということは次に実装すべきゴールは"a" | "b" | "c" | "d" | "e"から["a", "b", "c", "d", "e"]を作ることです。

UnionToUnionTuple<T>

TupleからUnionへの変換はIndexed Access Typesを使えばよかったです。しかし逆はというと、UnionからTupleに変換することはかなり難しいです。難しいというか、技術的な制約が伴います。この話題についてはTypeScriptの#13298が詳しいです。

https://github.com/microsoft/TypeScript/issues/13298#issuecomment-514082646

Conceptually unions are un-ordered, tuples are ordered

引用して意訳すると、Unionに順序はないがTupleには順序が伴うという概念の違いが影響しています。そのため筆者は妥協して、UnionToTuple<T>ではなくUnionToUnionTuple<T>を作ることにしました。"a" | "b"から["a" | "b", "a" | "b"]を作るというアプローチです。

この実装は、筆者自身のTypeScript力が今よりずっと低かった頃から参照してきたいくつかの先人の知恵を借りているため、本稿ではサンプルを全量掲載し、途中の過程についてはその参考にした記事を紹介します。

type UnionToIntersection<U> = (
  U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never;

type LastOf<T> = UnionToIntersection<
  T extends unknown ? () => T : never
> extends () => infer R
  ? R
  : never;

type Push<T extends unknown[], V> = [...T, V];

type UnionToTuple<
  T,
  L = LastOf<T>,
  N = [T] extends [never] ? true : false
> = true extends N ? [] : Push<UnionToTuple<Exclude<T, L>>, L>;

type Tuple<TItem, TLength> = [TItem, ...TItem[]] & {
  length: TLength;
};

/**
 * Tuple<string, 3> は [string, string, string] を返す
 * それを応用して 'a' | 'b' を ['a' | 'b', 'a' | 'b'] にする
 *
 * 'a' | 'b'
 *   => ['a' | 'b', 'a' | 'b']
 * 'a' | 'b' | 'c'
 *   => ['a' | 'b' | 'c', 'a' | 'b' | 'c', 'a' | 'b' | 'c']
 */
type UnionToUnionTuple<T> = Tuple<T, UnionToTuple<T>["length"]>;

参照した記事や文献は次のようなものがあります。ありがとうございます、その節は大変お世話になりました。この場を借りてお礼申し上げます。

isMatchedType<T>()のランタイム実装

ここまでくると、{ name: string; age: number; }というオブジェクトから["name" | "age", "name" | "age"]を導出できるようになります。そのため、あとはランタイム側で必須プロパティを検証すればよくなります。次のサンプルコードでは、isMatchedType()の実装と、内部実装であるassertObject()を掲載しています。

function isObject(v: unknown): v is Record<string, unknown> {
  if (typeof v !== 'object') {
    return false;
  }
  return v !== null;
}

function assertObject(
  v: unknown,
  target = ''
): asserts v is Record<string, unknown> {
  if (!isObject(v)) {
    throw new Error(`${target} should be object`.trim());
  }
}

/**
 * @param v
 * @param props
 * @param errorPropsRef
 *   エラーメッセージ表示用の配列、呼び元から参照を与えて、
 *   その配列参照に対してエラープロパティを格納し親がそれを副作用の結果として扱う。
 * @param target
 */
function isMatchedType<T extends object>(
  v: unknown,
  props: UnionToUnionTuple<keyof RemoveOptionalProperties<T>>,
  errorPropsRef: string[],
  target = ''
): v is T {
  assertObject(v, target);
  if (new Set(props).size !== props.length) {
    throw new Error('Invalid props');
  }
  return (props as readonly string[])
    .map((prop) => {
      const within = prop in v;
      if (!within) {
        errorPropsRef.push(prop); // mutate
      }
      return within;
    })
    .every((flag) => flag);
}

ここで注意したい点としてnew Set(props).size !== props.lengthとしてpropsをランタイムで検証しているという点です。UnionToTuple<T>が困難だったという理由でUnionToUnionTuple<T>にしているため、{ name: string; age: number; }というオブジェクト型に対して["name", "name"]["age", "age"]という指定が可能になってしまっているためです。この点は惜しいところで妥協点なのですが、わざわざ同じプロパティを重ねて書くこともないため、運用上の不都合はいまのところ起こっておらず、許容としています。

isMatchedType()は戻り型がv is Tとなっているため、この関数がtrueを返すのであればvT型であるとみなすようにしています。これはUser-defined Type Guardの紹介で説明しました。

assertMatchedType()

いよいよ最後です。isMatchedType()をラップした関数assertMatchedType()を実装しましょう。この実装では、errorPropsRefの配列参照を子関数に与えることによって、エラーとなっているプロパティをランタイム上で列挙してもらい、それを最終的なエラーメッセージとしている点です。このエラーハンドリングによってSentryなどのエラーログサービスを使っていても不具合の箇所がすぐわかるため、デバッグの効率は飛躍的に上がりました。

function assertMatchedType<T extends object>(
  v: unknown,
  props: UnionToUnionTuple<keyof RemoveOptionalProperties<T>>,
  target = ''
): asserts v is T {
  const errorPropsRef: string[] = []; // 子に値を格納させるための空配列参照
  if (!isMatchedType(v, props, errorPropsRef, target)) {
    // ここに該当するとき errorPropsRef 配列の中身にはエラー箇所が詰まっている。
    throw new PreconditionError(
      `${target} should be aligned type. ${
        0 < errorPropsRef.length ? `[${errorPropsRef.join(', ')}]` : ''
      }`.trim()
    );
  }
}

実装の本体はほぼisMatchedType()側で、assertMatchedType()はどちらかというとエラーログ整形を担っています。戻り型はasserts v is Tとなっており、これはAssertion Functionsです。

完成

これでassertMatchedType()のすべての実装が完成しました。改めて完成したコードの全量を確認しましょう。

export type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends <
  T
>() => T extends Y ? 1 : 2
  ? true
  : false;

export type RemoveNeverProperties<T> = Pick<
  T,
  {
    [P in keyof T]: [T[P]] extends [never] ? never : P;
  }[keyof T]
>;

type AnyAs0<T> = {
  [P in keyof T]: Equals<T[P], any> extends true ? 0 : T[P];
};

type UnknownAs0<T> = {
  [P in keyof T]: Equals<T[P], unknown> extends true ? 0 : T[P];
};

type NullAs0<T> = {
  [P in keyof T]: Equals<T[P], null> extends true ? 0 : T[P];
};

type Escape<T> = NullAs0<UnknownAs0<AnyAs0<T>>>;

type OptionalAsNever<T> = {
  [P in keyof T]-?: T[P] extends NonNullable<T[P]> ? T[P] : never;
};

/**
 * { a?: string; b: number; c?: any; d: any } を
 * { b: number; d: any } だけにする。
 *
 * AnyAs0<T> がないと ?: any への対応が漏れるので注意。
 */
export type RemoveOptionalProperties<T> = {
  [P in keyof RemoveNeverProperties<OptionalAsNever<Escape<T>>>]: T[P];
};

type UnionToIntersection<U> = (
  U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never;

type LastOf<T> = UnionToIntersection<
  T extends unknown ? () => T : never
> extends () => infer R
  ? R
  : never;

type Push<T extends unknown[], V> = [...T, V];

type UnionToTuple<
  T,
  L = LastOf<T>,
  N = [T] extends [never] ? true : false
> = true extends N ? [] : Push<UnionToTuple<Exclude<T, L>>, L>;

type Tuple<TItem, TLength> = [TItem, ...TItem[]] & {
  length: TLength;
};

/**
 * Tuple<string, 3> は [string, string, string] を返す
 * それを応用して 'a' | 'b' を ['a' | 'b', 'a' | 'b'] にする
 *
 * 'a' | 'b'
 *   => ['a' | 'b', 'a' | 'b']
 * 'a' | 'b' | 'c'
 *   => ['a' | 'b' | 'c', 'a' | 'b' | 'c', 'a' | 'b' | 'c']
 */
export type UnionToUnionTuple<T> = Tuple<T, UnionToTuple<T>["length"]>;

function isObject(v: unknown): v is Record<string, unknown> {
  if (typeof v !== "object") {
    return false;
  }
  return v !== null;
}

export function assertObject(
  v: unknown,
  target = ""
): asserts v is Record<string, unknown> {
  if (!isObject(v)) {
    throw new Error(`${target} should be object`.trim());
  }
}

/**
 * @param v
 * @param props
 * @param errorPropsRef
 *   エラーメッセージ表示用の配列、呼び元から参照を与えて、
 *   その配列参照に対してエラープロパティを格納し親がそれを副作用の結果として扱う。
 * @param target
 */
export function isMatchedType<T extends object>(
  v: unknown,
  props: UnionToUnionTuple<keyof RemoveOptionalProperties<T>>,
  errorPropsRef: string[],
  target = ""
): v is T {
  assertObject(v, target);
  if (new Set(props).size !== props.length) {
    throw new Error("Invalid props");
  }
  return (props as readonly string[])
    .map((prop) => {
      const within = prop in v;
      if (!within) {
        errorPropsRef.push(prop); // mutate
      }
      return within;
    })
    .every((flag) => flag);
}

export function assertMatchedType<T extends object>(
  v: unknown,
  props: UnionToUnionTuple<keyof RemoveOptionalProperties<T>>,
  target = ""
): asserts v is T {
  const errorPropsRef: string[] = []; // 子に値を格納させるための空配列参照
  if (!isMatchedType(v, props, errorPropsRef, target)) {
    // ここに該当するとき errorPropsRef 配列の中身にはエラー箇所が詰まっている。
    throw new Error(
      `${target} should be aligned type. ${
        0 < errorPropsRef.length ? `[${errorPropsRef.join(", ")}]` : ""
      }`.trim()
    );
  }
}

assertMatchedType()関数を使った業務での実装例は次のようになります。このコードは業務上の実際のコードではなく、雰囲気を似せたサンプルではありますが、業務上のコードもほぼこんなコードです。

type FilledString = Brand<string, "FilledString">;
type UserId = Brand<FilledString, "UserId">;
type Email = Brand<FilledString, "Email">;

type User = {
  id: UserId;
  name: FilledString;
  email: Email;
  url: string;
  description: string;
}

function adaptUser(res: unknown): User {
  assertMatchedType<{
    id: unknown;
    name: unknown;
    email: unknown;
    url?: unknown;
    description?: unknown;
  }>(res, ['id', 'name', 'email']);

  return {
    id: asUserId(res.id),
    name: asFilledString(res.name),
    email: asEmail(res.email),
    url: asString(res.url) ?? "",
    description: asString(res.description) ?? "",
  };
}

ここで出てくるasUserId()asFilledString()については『実例 FilledString, UserId』にて紹介しました。

assertMatchedType()の注意点は、assertMatchedType<T>()Tstringnumberと書いても、そこまでは見ないという点です。そのため、ここはすべてunknownにしておき、それぞれのプロパティごとに検証する方が全体的な実装としてはイレギュラーに対応しやすいと判断しました。

また、筆者の案件ではこれ以外にAjvも併用しており、すべての箇所でassertMatchedType()を使うのではなく提供された資源に応じて検証手段や検証強度を変更するようにしています。業務でのAjvの扱いについては『Mapping Modifiersと実例 Writable<T, K>』にて言及しました。

総括

おつかれさまです。以上がassertMatchedType()の実装モチベーション、そして実装内部の詳細でした。

これはMapped Types, Conditional Types, Assertion Functionsをすべて組み合わせTypeScriptに備わった機能を最大限に活かして、そして多くの世界中の開発者の知恵を授かりながら生まれたものです。筆者がすべて一人で考えついたものではありません。あらゆるドキュメント、ブログ、技術記事、ディスカッションなどに知識が溢れており、そういった集合知によって次のひらめきが生まれた結果です。

本稿、そして本アドベントカレンダーを通じて筆者が伝えたかったことはひとつです。「TypeScriptには強力な機能で溢れている」ということ。ECMAScriptのstringnumberを見分けられるだけではないのです。そして極めて難解なものではなく、業務上でありふれているということをお伝えしたく、ここまで執筆しました。

本アドベントカレンダーの目的はリファレンスガイドの提供ではなく、筆者の蓄えてきた知識や経験を体系的に言語化し、それをTypeScript学習者に届けるというものです。1日目から25日目までの25記事があなたのTypeScriptを少しでも彩ることができるのなら、筆者としてそれはこの上なく幸せです。

本アドベントカレンダーは以上です。最後までお読みくださいましてありがとうございました。

Discussion