🎄

String Literal Typesとas const / TypeScript一人カレンダー

2022/12/24に公開

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

enum

TypeScriptには、0.9からenumという機構があります。これはTypeScriptの機能としては稀である、ECMAScriptと明らかに非互換である機能です。

https://www.typescriptlang.org/docs/handbook/enums.html

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 TypesUnion 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のプロパティの型に直接値を定義することで、stringnumberではなく、より厳密に格納可能な値を示すことができました。そのため変数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[]になってしまいます。そこで、変数suitsstring[]ではなく有限長のTupleであるということをコンパイラに伝える必要があります。そのために使うものがas constです。

as constは"const Assertion"という仕様でまとめられており、これは昨日紹介したType Assertionsに登場するasとは異なるasであることに注意が必要です。

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions

as constを付与することでconst suitsreadonly ["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