実例 FilledString, UserId / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@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" }
型は、number
とnumber
の部分が一致しています。よって「部分的に」一致しているため互換があるとみなされ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()
としてnull
やundefined
であるかを検証しましたが、同様に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()
によってv
がstring
として推論して問題ないことが確定した状態で、その値を返すというものです。これらは過去の記事でも述べたとおり、単体テストの実装が必須です。
FilledString
asString()
の関数が実装できたことで、もう一歩踏み込んで「undefined
とnull
に加えて空文字列も許容しない」という検証関数を実装してみましょう。
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
型パラメータを使って反復処理しながら、そのプロパティ自体もなんらかの計算を適用させたい場合に使います。
今回であれば、[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
型が頻出してしまう状況であれば取り違えを防ぐことに繋がるため、いざというときには心強い仕組みです。
as const
』
明日は『String Literal Typesと本日はプリミティブ型を動的に検証しつつ型を区別していくというBrand<K, T>
の応用編でした。明日は複数の文字列のまとまりを管理するためのString Literal Typesとas const
について紹介します。それではまた。
Discussion