🎄

実例 FilledString, UserId / TypeScript一人カレンダー

2022/12/24に公開約9,500字

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の19日目です。昨日は『Branded Typesを導入してみる』を紹介しました。

Branded Typesの改良

昨日のBranded Typesについての記事では、主に利点を紹介しました。本日はその続きとして、今度は昨日の内容に含まれる欠点を紹介し、それを克服した上で業務に役立てていく実例を紹介します。

Branded Typesのおかげで、クラスのインスタンスを作る必要がなくシリアライズにも強くなるという話題を昨日しました。ところが、ではなぜプリミティブ値でありながら他の型と区別をつけられるのかといった部分を深堀りすると、Type Assertions (as) を採用している点が気になります。

Type Assertionsが使えるときと、使えないとき

Type Assertionsは状況次第では便利ですが、リスクを伴う操作であることを認識せねばなりません。まずはリスクのない操作について紹介します。例えば次のコードはstring型の値であるためnumber型とみなすことはできません。

const value = "hello" as number;
// Error: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other.
// If this was intentional, convert the expression to 'unknown' first.(2352)

"hello"はもちろんstring型なので、number型とみなすことはできません。エラーとなります。しかし、初学者はしばしばエラーメッセージの"If this was intentional"を曲解してしまいます。エラーを取り除くためにはas unknown asをつければよいと書かれているように受け取ってしまうことがあります。

その認識は誤解であり、そういった操作は誤りを生みます。as unknown as Tは「エラーを消すことができる」としても、"hello"という値がnumber型に変化するなんてことはなく、元がstring型であること自体は変わらないからです。そのため、エラーを消したいようであればas unknown asを付けるのではなく、そもそも不可能なType Assertionsはせずに、それ自体を除去するというのが正しいです。

昨日紹介したBrand<K, T>は、なぜas unknown asを付けずともエラーにならずType Assertionsとして記述できるのでしょうか。それにはやはり昨日紹介したStructural Typingの考え方が絡みます。

type Brand<K, T> = K & { __brand: T }

type USD = Brand<number, "USD">
type EUR = Brand<number, "EUR">

const usd = 10 as USD;
const eur = 10 as USD as EUR;

number型とnumber & { __brand: "USD" }型は、numbernumberの部分が一致しています。よって「部分的に」一致しているため互換があるとみなされ10 as USDは問題なく記述できます。TypeScriptはStructural TypingでありながらSubtypingを採用しているため、部分的な一致は互換であるとみなされます。

ところが10 as USD as EURはエラーです。これは{ __brand: "USD" }かつ{ __brand: "EUR" }であるという状況が起こり得ないためです。これは期待通りのエラーです。as unknown asを付けずともエラーになるか、ならないかで適切なType Assertionsであるかどうかの安全さが測れます。

Type Assertionsが危険である状況

一方で、Type Assertionsを明らかに使ってはならない状況があります。それはany型の値に対するType Assertionsの付与です。例えば次のコードでは2つのエンドポイントからUserの配列とItemの配列を取得しようとしています。asに注目してください。

const res1 = await fetch("/api/users", { method: "GET" });
const users = await res1.json() as User[];

const res2 = await fetch("/api/items", { method: "GET" });
const items = await res2.json() as User[];

window.fetch()の戻り値が備えるメソッドjson()Promise<any>として定義されているため、await res1.json()await res2.json()はどちらもany型になってしまいます。ここでas User[]としてType Assertionsを記述し、変数usersと変数itemsをそれぞれユーザーの配列、商品の配列として扱おうとしていますが、await res2.json() as User[];についてはas Item[]でなければなりません。

こういったas以降の型の誤りは、コードをコピペして開発するようなスタイルでは頻発しやすく、筆者が過去に参加した案件でも実際に遭遇してしまった事態です。「コンパイラがエラーとしない限り不具合は混入しない」と信じてしまうTypeScript初学者によって比較的起こりやすく、any型に対するasがいかに危険であるかを理解していないまま既存コードのコピペをすることで、実装を量産している際に見落とされやすい箇所です。

筆者はこのリスクがあるために、asを記述する対象の値がどんな型で推論されていようとも、わずかでも残るリスクを避けるためas自体をかなり厳しく取り扱っています。エラーにならず安全であるasの使い方と、エラーにはならないが危険であるasの使い方を、多くの現場においてすべての開発者が等しく熟知している状況とは言い難いためです。

Assertion Functionsを採用する

asのリスクを回避するためには、コンパイラにどう「みなしてもらうか」に加えて、実行時にも実際に検証することが肝要です。そのためにはAssertion Functionsを採用します。

以前紹介したAssertion Functionsの例ではassertExists()としてnullundefinedであるかを検証しましたが、同様にasを使いたい状況の数だけassertSomething()関数を宣言していきます。

次の節では、実際にAssertion Functionsを宣言する様子を紹介します。

assertString()asString()

Type Assertionsによってas stringと書きたい場面があったとします。ですが前節で述べたようにasは回避したいものです。そこで、assertString()関数とasString()関数を宣言しました。次のコードでその実装を紹介します。

function isString(v: unknown): v is string {
  return typeof v === 'string';
}

function assertString(v: unknown, target = ''): asserts v is string {
  if (!isString(v)) {
    throw new Error(`${target} should be string`.trim());
  }
}

function asString(v: unknown): string {
  assertString(v);
  return v;
}

assertString()関数は戻り型がasserts v is stringとなっており、これはAssertion Functionsです。isString()関数はv is stringであるためType Predicate Signatureを伴うUser-defined Type Guardです。両方とも詳細は『NonNullable<T>と実例 assertExists()』の回で紹介しています。

そして、毎回assertString()を使うとなると、まだいささか冗長であるため、利便性のためにユーティリティ関数としてasString()も実装します。これは見ての通り、assertString()によってvstringとして推論して問題ないことが確定した状態で、その値を返すというものです。これらは過去の記事でも述べたとおり、単体テストの実装が必須です。

FilledString

asString()の関数が実装できたことで、もう一歩踏み込んで「undefinednullに加えて空文字列も許容しない」という検証関数を実装してみましょう。

function isFilledString(v: unknown): v is string {
  return isString(v) && v !== "";
}

function assertFilledString(
  v: unknown,
  target = ""
): asserts v is string {
  if (!isFilledString(v)) {
    throw new PreconditionError(`${target} should be not empty string`.trim());
  }
}

function asFilledString(v: unknown, target = ""): string {
  assertFilledString(v, target);
  return v;
}

isFilledString()関数はisString()関数での検証に加えて!== ""であることを検証しています。これでasFilledString()関数に渡した引数は、必ず1文字以上の文字列であることが保証されるようになりました。

ですが、このままだとstring型として扱われてしまいます。isFilledString()関数での検証を済ませているようであればstring型と区別できる別の型を扱うようにしましょう。プリミティブ型でありながら型を区別したい、そんなときに有用なのがBranded Typesです。次の例ではtype FilledStringを宣言します。

type Brand<K, T> = K & { __brand: T }
type FilledString = Brand<string, "FilledString">;

function asFilledString(v: unknown, target = ""): FilledString {
  assertFilledString(v, target);
  return v;
}

昨日紹介したBranded Typesの欠点はasを使って型をみなしていた点でした。今回の例ではその欠点を克服し、Assertion Functionsを伴うことでコンパイルタイムでもランタイムでも値の正当性が検証されるようになりました。

アレンジしたBrand<K, T>で宣言するUserId

FilledString型が使えるようになったことで、もう一歩踏み込んで活用してみましょう。たとえばユーザーのアカウントを特定するためのIDを識別する型を宣言してみます。こういったIDは一般的に空文字列であるはずがなく、連番かなんらかのランダムな文字列であることが多いです。そこでUserId型を次のように宣言してみましょう。

type Brand<K, T> = K & { __brand: T }
type FilledString = Brand<string, "FilledString">;
type UserId = Brand<FilledString, "UserId">;
//   ^? never

FilledString型でありながらUserId型として識別できるようにBrand<FilledString, "UserId">を宣言しています。ところが、残念ながらneverになってしまいました。

これはその通りで、展開するとstring & { __brand: "FilledString" } & { __brand: "UserId" }を宣言しようとしていることと同義であるため、"FilledString"かつ"UserId"である値は存在し得ないことからneverになってしまうのです。そこで筆者は昨日紹介したBrand<K, T>にさらにアレンジを加えています。

type Underscore<P extends string> = `__${P}`;
type Underscored<T extends string> = { [P in T as Underscore<P>]: Underscore<P> };

type Brand<K, T extends string> = K & Underscored<T>;

type FilledString = Brand<string, "FilledString">;
type UserId = Brand<FilledString, "UserId">;
//   ^? string & Underscored<"FilledString"> & Underscored<"UserId">

Underscore<P>Template Literal Typesを使って、受け取った文字列に__を追加する型です。__を追加する理由は、元ネタのBrand<K, T>と同じく既存のECMAScriptの他のプロパティ名と競合しないようにするためです。そして、そのように__を追加した文字列を使ってオブジェクトを作る型Underscored<T>を宣言しています。

[P in T as Underscore<P>]は新しく紹介する表記です。TypeScript 4.1から、Mapped Typesに新たな仕様が実装されました。Key Remapping via asという仕様は、Mapped TypesとしてP型パラメータを使って反復処理しながら、そのプロパティ自体もなんらかの計算を適用させたい場合に使います。

https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as

今回であれば、[P in T]: Underscore<P>であれば{ UserId: "__UserId" }になるところですが、Key Remapping via asのおかげで{ __UserId: "__UserId" }とすることができます。

このようなアレンジを加えることで、FilledStringかつUserIdであるというBrandの宣言ができるようになりました。使う際はas UserIdと記述せずに、ちゃんとAssertion Functionsを使いましょう。たとえばUserId型とItemId型を扱う際は次のようにします。

type UserId = Brand<FilledString, "UserId">;
type ItemId = Brand<FilledString, "ItemId">;

function asUserId(v: unknown): UserId {
  assertFilledString(v, "UserId");
  return v;
}

function asItemId(v: unknown): ItemId {
  assertFilledString(v, "ItemId");
  return v;
}

この手法で複数のIDを区別することが容易になります。データベースから複数のEntityを取得して扱うようなバックエンド処理を書いているときなどに非常に強力です。この手法を応用すると、整数値のみであることを示すInteger型やミリ秒を示すMillisecond型を作ったり、MailAddress型やTel型などを作ることも容易になります。あまりにも細かく分けすぎてしまうとそれはそれで逆に煩雑になってしまうため、その踏み込み具合は裁量によるのですが、string型やnumber型が頻出してしまう状況であれば取り違えを防ぐことに繋がるため、いざというときには心強い仕組みです。

明日は『String Literal Typesとas const

本日はプリミティブ型を動的に検証しつつ型を区別していくというBrand<K, T>の応用編でした。明日は複数の文字列のまとまりを管理するためのString Literal Typesとas constについて紹介します。それではまた。

Discussion

ログインするとコメントできます