String Literal Typesとas const / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の20日目です。昨日は『実例 FilledString
, UserId
』を紹介しました。
enum
TypeScriptには、0.9からenum
という機構があります。これはTypeScriptの機能としては稀である、ECMAScriptと明らかに非互換である機能です。
TypeScriptは登場当時から4.9に至るまで、ECMAScriptに型システムをもたらすという方向性で進化を遂げていますが、enum
に関してはTypeScript独自の機能であり、かつECMAScriptとしての実行時にも大きく作用する機能です。今思えばですが、TypeScript 0.9で採用されたというだけあって、まだTypeScriptという言語の方向性が定まっていなかったなと感じます。
enum
とは、特定の値の集まりを1つのenum
として定義できる仕組みです。冒頭でも警告したように筆者はenum
の使用は推奨せず、具体的なサンプルコードも本稿では省略しますので、内容を把握したい場合は公式ドキュメントをご確認ください。
非推奨の内容であれば触れないこともできたのですが、TypeScript 1.xや2.xの当時に書かれたenum
の使い方を解説する技術記事が未だに検索にヒットしたり、もう少し後の時期になって、潮目の変わってきたenum
の扱いやその代替について述べる記事などが増えていることから、enum
というものが未だにTypeScript学習において惑わす一因になっているとして、筆者としては非推奨であるという話題のみ取り上げました。
次の節から、特定の値の集まりをTypeScript上でどのように定義し管理すればよいか、おすすめの方法を紹介します。
String Literal TypesとUnion Types
ここからが本稿の本題です。たとえばトランプのスート(マーク)をコード上で表現したい場合、Spade, Heart, Diamond, Clubという4単語を定数値として表したいことがあります。トランプのカード一枚一枚を表現するためのオブジェクト型としてCard
を定義してみましょう。
type Card = {
suit: string;
num: number;
};
const spadeA: Card = {
suit: "spade",
num: 1,
};
const diamond9: Card = {
suit: "diamond",
num: 9,
};
const unknownCard: Card = {
suit: "eagle",
num: 42,
}; // No Error
このコードはまだまだ不安があります。suit: "eagle"
というように4つのスートに含まれない任意の文字列を格納できてしまいますし、num: 42
という番号を格納することができてしまいます。そこでString Literal TypesとUnion Typesを組み合わせて、より値を限定できるようにしましょう。
type Card = {
suit: "spade" | "heart" | "diamond" | "club";
num: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
};
const spadeA: Card = {
suit: "spade",
num: 1,
};
const diamond9: Card = {
suit: "diamond",
num: 9,
};
const unknownCard: Card = {
suit: "eagle",
num: 42,
}; // Error
type Card
のプロパティの型に直接値を定義することで、string
やnumber
ではなく、より厳密に格納可能な値を示すことができました。そのため変数unknownCard
はエラー扱いになりました。
文字列リテラルや数値リテラルをtype
宣言上でもconst
宣言上でも扱えるというのは、慣れていないと不思議に思うかもしれません。TypeScriptではこういったひとつひとつの値についても型として扱うことができます。これらを総称してLiteral Typesと呼びます。
特に文字列に関してはString Literal Typesと呼び、こちらの呼称の方が日本では知られている印象です。冒頭で取り上げたenum
を使ってSpade, Heart, Diamond, Clubの4つを定義する方法を取り上げた文献もいくつかありますが、あまりにも古いため現代ではこのようにString Literal Typesを使うのが一般的です。
さて、ここまではTypeScriptの機能の中でもまだ初歩と言える内容で、以前の記事にて既にもっと発展的で高度なTemplate Literal Typesを紹介しているものですから、本稿でももう少し応用的な扱いを紹介しましょう。
isSuit()
関数を実装する
ある文字列がスートの定義に含まれるかどうかを判定したくなったとします。そこでisSuit()
関数を実装しましょう。この関数の引数v
が"spade"
, "heart"
, "diamond"
, "club"
のいずれかと等しければtrue
を返すようにします。
function isSuit(v: string): boolean {
if (v === "spade") {
return true;
}
if (v === "heart") {
return true;
}
if (v === "diamond") {
return true;
}
if (v === "club") {
return true;
}
return false;
}
うーん、初めてプログラミングでif
文を使ってみました感が出てしまいました。もうちょっとクールに書くにはどうすればよいでしょうか。先にスートになりうる4値を定義しておき、引数v
はその定義に含まれるかどうかを確認するとよさそうです。
定数を先に宣言しておくリファクタリング
const suits = ["spade", "heart", "diamond", "club"];
function isSuit(v: string): boolean {
return suits.includes(v);
}
console.log(isSuit("spade")); // true
console.log(isSuit("hello")); // false
できました。if
文を4つも並べてしまうよりずっとスマートです。では、先に紹介したCard
型の定義と並べて眺めてみましょう。
const suits = ["spade", "heart", "diamond", "club"];
function isSuit(v: string): boolean {
return suits.includes(v);
}
type Card = {
suit: "spade" | "heart" | "diamond" | "club";
num: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
};
なるほど、だいぶよくなりました。
ただ、const suits
の変数宣言側とsuit
のプロパティの型宣言側の2回も、4つのスートの記述が並んでしまっています。トランプは常に4スートだから多少重複していても問題ない?いやいや、スパイダーというソリティアゲームの遊び方のひとつには、SpadeとHeartの2スートだけを使うというものもあります。
const suits = ["spade", "heart"]; // suits変数側は直した
type Card = {
suit: "spade" | "heart" | "diamond" | "club"; // Card型は直し忘れ
num: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
};
変数側だけ修正し、型定義の修正は忘れられてしまいました。しかしこれではコンパイルエラーとはなりません。そのためこのままでは、両方の箇所を一緒に更新することを意識せねばなりません。こういった状況はトランプゲームの実装に限らず、業務上で割と頻発します。こうならないように型定義と値宣言を別々に記述するのではなく、先に定数宣言をして、その定数から型を定義するという流れにするとスマートです。
定数から型定義を作りたい
ではさっそくやってみましょう。先にconst suits
として定数を宣言して、そこからSuit
型を定義する流れに注目してください。
const suits = ["spade", "heart", "diamond", "club"];
type Suit = typeof suits;
なんとなくできてそうな見た目です。ところが残念、デバッグしてみるとSuit
型はstring[]
であるとみなされてしまいます。これは変数suits
に含まれる値が「この4値ですべてである」とコンパイラ側では推論できずに、無限長のstring
の配列であるとみなさざるを得ないためです。
このように推論時に型のとりうる可能性を最大限広めに解釈する挙動をType Wideningといいます。この文献については公式ドキュメントのType Widening and Narrowingという項目に記されています。Type Wideningの挙動そのものの詳細な挙動の解説は、本稿では省略します。
as const
Type Wideningが起こってしまうと無限長のstring[]
になってしまいます。そこで、変数suits
がstring[]
ではなく有限長のTupleであるということをコンパイラに伝える必要があります。そのために使うものがas const
です。
as const
は"const
Assertion"という仕様でまとめられており、これは昨日紹介したType Assertionsに登場するas
とは異なるas
であることに注意が必要です。
as const
を付与することでconst suits
はreadonly ["spade", "heart", "diamond", "club"]
型であるとみなされます。すなわちlength 4のreadonly
なTupleです。ここまで来たらあとはTupleをUnionに変換するだけです。この方法は実は5日目に紹介しています。Indexed Access Typesです。
const suits1 = ["spade", "heart", "diamond", "club"] as const;
type Suit1 = typeof suits1[number];
// ^? "spade" | "heart" | "diamond" | "club"
const suits2 = ["spade", "heart"] as const;
type Suit2 = typeof suits2[number];
// ^? "spade" | "heart"
できました!こうすることで、Suit1
型もSuit2
型も定数値と定義に参照の関係が生まれ、一意に求まるようになりました。
筆者は業務で、このas const
付き定数値TupleからUnionの型定義を生み出す手法を常に取り入れており、もしバックエンドのデータベースの都合などでやむを得ずenum的な整数値0, 1, 2
しか返されないような状況でも、TypeScriptのenum
を使うことなく定数値TupleからstatusDef[0]
のように可読性のある文字列値に変換して扱うようにしています。
const statusDef = ["pending", "active", "deleted"] as const;
const entity = /* { status: number; } を含むデータベースから得られたオブジェクト */
console.log(statusDef[entity.status]); // "pending"
もちろん整数値のまま扱ってもよいのですが「何番は何」と把握しておく必要があるため、さっさと文字列にした方が、後々のデバッグやテストの作成はやりやすいです。
if
文とswitch
文は使い分ける?』
明日は『Union TypesもString Literal Typesも、それぞれ個別には扱えるというTypeScript利用者は多いと思います。今回はそれらを組み合わせて、複数のTypeScriptの機能を活用しながらスマートに定数と型の両方を定義していく手法を紹介しました。このようにTypeScriptでは、機能を知識の引き出しとして備えていればいるほど、あらゆる実装をスマートに書けるようになります。
明日は一旦ひとやすみ。コラム的なものとして業務中のちょっとした疑問に答えていきます。それではまた。
Discussion