🗃️

型安全Storageの実装を通して学ぶ! TypeScriptの型

2021/12/12に公開

まえがき

TypeScriptの型システムは、他のプログラミング言語にはないユニークな機能を多く備えています。これは、動的型付け言語であるJavaScriptで書かれたコードの振る舞いを(静的)型によってモデル化するという難題に対する答えであり、結果としてTypeScriptの型は比肩するものがないほど強力な表現力を持つことになりました。

TypeScriptの型システムが持つ機能の詳細な解説に関しては、既に素晴らしい記事が星の数ほどあります。しかし、それらの機能をどう応用すれば作りたいものを作れるのか?という知識は、実際にものを実装してみるという活動を通してしか得られないものだと感じます。

そこで、この記事では型安全なStorageラッパーの実装を題材とし、その過程を共有していきます。

想定読者

  • TypeScriptの型システムの機能に関する知識はあるが、応用方法がいまいちピンとこない方
  • JavaScriptが抱える型安全性の問題をTypeScriptで解決する方法に興味がある方
  • 前提: TypeScriptの文法、ジェネリクスに関する基本的な知識

Web Storageが抱える問題点

型安全Storageラッパーの実装に移る前に、Web Storageをそのまま利用する場合に発生する問題について考えます。

まず、Web Storageは単純なKey-Value Storeであり、値を文字列の形でしか保存できません。そのため、他の型の値を保存したければ何らかの方法で値を文字列表現に変換してから保存し、取得する際は保存されている文字列表現をパースして元の型の値に復元する必要があります。

この問題に対処するにあたり、まず思いつくのは JSON.stringify()/JSON.parse()を使う方法でしょう。

type Person = { name: string, age: number };

// オブジェクト値をJSON文字列に変換してStorageに保存
const p = { name: "jiftechnify", age: 27 };
localStorage.setItem("item", JSON.stringify(val));

// StorageからJSON文字列を取得し、それをパースして元の値を復元
const json = localStorage.getItem("item");
const p2 = JSON.parse(json) as Person;

この方法で好きな値をStorageに保存できますが、やはりいくつか問題点があります。

  1. JSON文字列以外の文字列表現を使いたい場合や、そもそもJSONに変換できない値を扱いたい場合に対応できない
  2. JSON.parse()の結果を元の型に復元する際に、安全でない型アサーション(上記の例でいうas Person)が必要となる
    • JSON.parse()の結果が思っていたのと違う型の値だったとしても、実行するまで誰も怒ってくれない!

これらの問題に対処する方法としてまず考えられるのは、保存したい値の型ごとに専用のsetItem/getItemメソッドを用意したラッパーを用意するというものです。

class StorageWrapper {
  // それぞれの型に対応したgetItem
  public static getString(key: string): string | null { ... }
  public static getNumber(key: string): number | null { ... }
  public static getPerson(key: string): Person | null { ... }
  
  // それぞれの型に対応したsetItem
  public static setString(key: string, val: string): void { ... }
  public static setNumber(key: string, val: number): void { ... }
  public static setPerson(key: string, val: Person): void { ... }
}

StorageWrapper.setPerson("foo", { ... });
const foo = StorageWrapper.getPerson("foo"); // foo: Person | null

この方法で上記の1.の問題は解決できますが、2.の問題は根本的には解決できていません。というのも、あるキーについて、値を保存するときと取得するときで別々の型に対応するメソッドを使ってしまうというミスを防げないのです。

StorageWrapper.setString("bar", "brabra"); // stringを保存したのに...
const n = StorageWrapper.getNumber("bar"); // numberとして取り出そうとしてしまった!

よって、このラッパー実装を利用する開発者は、キーとそれに紐つく値の型の関係を常に頭に入れておかねばなりません。これはDX的な観点からも問題だといえるでしょう。

理想形を考える

前節の考察を踏まえ、理想的なStorageラッパーに求められる要件を挙げていきます。

  • 文字列に限らない好きな型の値を保存でき、取得時に間違いなく元の値を復元できる
  • 値の型ごとに、文字列との間の変換の方法をカスタマイズできる
  • キーさえ指定すれば、それに紐つく値の型を自動的に推論してくれる

具体的には、以下のようなコードが書けるStorageラッパーが欲しいのです。

// キーとその値の対応関係を指定してStorageラッパーを生成(仮想的なコード)
const storage = createStorage({
  str: string,
  num: number,
});

storage.set(" // ここでIntelliSenseすると使えるキー一覧が出てきてほしい!
storage.set("str", // この時点で与えるべき値の型が分かるようにしたい!

storage.set("str", "string") // OK
storage.set("num", 1)        // OK
storage.set("num", "string") // type error
storage.set("unknown", {})   // type error

const s = storage.get("str") // s: string | null
const n = storage.get("num") // n: number | null
const u = storage.get("unknown") // type error

型安全Storageラッパーの完成形

さて、以上の要件を満たすStorageラッパーをどのように実装すればいいのでしょうか…?

結論から示すと、以下のような実装になりました。俺の答えはこれや!!!

/** Storageの基本機能を抽象したI/F */
export interface BaseStorage {
  get(key: string): string | null;
  set(key: string, value: string): void;
  remove(key: string): void;
}

/** 型Tの値と文字列との間の相互変換方法を規定するオブジェクトのI/F */
export interface Codec<T> {
  encode: (t: T) => string;
  decode: (s: string) => T;
}

/** キーと、それに紐つく値を処理するCodecの対応関係の型 */
type StorageCodecSpec = Record<string, Codec<any>>;

/** StorageCodecをとり、利用可能なキーの集合をunion型として返す型関数 */
type StorageKeys<Spec extends StorageCodecSpec> = {
  [K in keyof Spec]: K extends string ? K : never;
}[keyof Spec];

/** StorageCodecと1つのキーをとり、そのキーに紐つく値の型を返す型関数 */
type StorageValTypeOf<
  Spec extends StorageCodecSpec,
  K extends StorageKeys<Spec>
> = Spec[K] extends Codec<infer T> ? T : never;

/** 型安全StorageラッパーのAPI */
interface TypedStorage<Spec extends StorageCodecSpec>{
  get<K extends StorageKeys<Spec>>(key: K): StorageValTypeOf<Spec, K> | null;
  set<K extends StorageKeys<Spec>>(key: K, value: StorageValTypeOf<Spec, K>): void;
  remove(key: StorageKeys<Spec>): void;
};

/** 型安全Storageラッパーのコンストラクタ */
const createTypedStorage = <Spec extends StorageCodecSpec>(
  spec: Spec,
  baseStorage: BaseStorage
): TypedStorage<Spec> => {
  const keyToCodec = spec;
  const baseStrg = baseStorage;

  return Object.freeze({
    get<K extends StorageKeys<Spec>>(key: K): StorageValTypeOf<Spec, K> | null {
      const rawVal = baseStrg.get(key);
      if (rawVal === null) {
        return null;
      }
      const codec = keyToCodec[key] as Codec<StorageValTypeOf<Spec, K>>;
      return codec.decode(rawVal);
    },
    set<K extends StorageKeys<Spec>>(key: K, value: StorageValTypeOf<Spec, K>): void {
      const codec = keyToCodec[key] as Codec<StorageValTypeOf<Spec, K>>;
      const encoded = codec.encode(value);
      baseStrg.set(key, encoded);
    },
    remove(key: StorageKeys<Spec>): void {
      baseStrg.remove(key);
    },
  });
};
利用側のコード例
const baseStorage: BaseStorage = ...;
const strCodec: Codec<string> = ...;
const numCodec: Codec<number> = ...;

const storage = createTypedStorage({
  str: strCodec,
  num: numCodec
}, baseStorage);

// このstorageを使えば「理想形を考える」の節で提示したようなコードが書ける!

このコードを見て「あー そーゆーことね」と完全に理解した方はこれ以降を読む必要はありません(ほんまか)。以降、この型安全Storageラッパーの実装について要素ごとに解説していきます[1]

型安全Storageラッパー実装の解説

Codec: 文字列との間の相互変換ロジック

export interface Codec<T> {
  encode: (t: T) => string;
  decode: (s: string) => T;
}

先に説明した通り、Storageで文字列以外の型の値を扱うには、文字列との間の相互変換を行う必要があります。その相互変換のインタフェースを規定するのがこのCodec<T>になります。これら2つの関数が行うべき処理はシグネチャを見れば明らかでしょう。

Storageラッパーは、T型の値を保存/取得する際に与えられたCodec<T>型のオブジェクトを利用して変換処理を行います。

基本的な型に対するCodecの具体的な実装は、例えば以下のようになるでしょう。

string
const stringCodec: Codec<string> = {
  encode: (s: string) => s,
  decode: (s: string) => s,
}
number
// 簡単のため、エッジケースへの対処は省略しています
const numberCodec: Codec<number> = {
  encode: (n: number) => n.toString(),
  decode: (s: string) => {
    const n = Number(s);
    if (isNaN(n)) {
      throw new Error("input is not decodeable as number");
    }
    return n;
  },
}

StorageCodecSpec: キーと値のCodecの対応関係の型

type StorageCodecSpec = Record<string, Codec<any>>;

今回実装したStorageラッパーでは、生成時にキーとそれに紐つく値を処理するCodecの対応関係を表すオブジェクトを指定する仕組みになっています[2]。そのオブジェクトが満たすべき制約を表現するのがこの型になります。

Record<K, V>は、簡単にいえば「すべてのプロパティの型がVであるようなオブジェクト」を表す型です。また、Codec<any>は、「型引数Tに入る具体的な型はなんでもよいが、とにかく何らかのCodec」という意味になります[3]。すなわち、StorageCodecSpecは「すべてのプロパティの値は何らかの型を処理するCodecでなければならない」という制約を表現します。

この型に代入可能なオブジェクトと、代入できないオブジェクトの例を示します。

代入可能な例
const numCodec: Codec<number> = ...;
const strCodec: Codec<string> = ...;
const spec: StorageCodecSpec = {
  num: numCodec,
  str: strCodec,
  1: strCodec, // OK...? :thinking_face:
},
代入不可能な例
// 1つでもプロパティの値がCodecではない場合はエラー
const ng: StorageCodecSpec = {
  num: 1,
  str: "string",
  hoge: {},
},

代入可能な例で、Recordのキーの型をstringと指定しているにもかかわらずnumberをキーとするプロパティが受け入れられているのが気になります。これはTypeScriptの細かい仕様によるもののようで[4]、ここでエラーとするのは難しそうなので、別の箇所で対処します。

StorageKeys: 利用可能なキーの集合を求める型関数

type StorageKeys<Spec extends StorageCodecSpec> = {
  [K in keyof Spec]: K extends string ? K : never;
}[keyof Spec];

Storageラッパーの生成時に与えられたStorageCodecSpecに含まれるキーだけをget/setの引数として指定できるようにしたいので、StorageCodecSpecから、そのStorageにおいてキーとして使える文字列の集合(文字列リテラルのunion型)を求める型関数(型をとって別の型を返すtype)を用意します。

type StorageKeys<Spec extends StorageCodecSpec> = keyof Specで十分なのでは…?と思われるかもしれませんが、先程も述べたTypeScriptの仕様(?)の関係で、具体的なSpecの型が定まっていない状況ではkeyof Specstring | number | symbolと推論されてしまうため、string以外の可能性を排除するために少々複雑な実装になっています。具体的には、TypeScriptならではの型の機能を組み合わせ、Specに含まれる文字列でないキーを結果から除外しています。順を追って解説します。

まず、K extends string ? K : neverの部分はConditional Typesです。型レベルの三項演算子だと思えばいいでしょう。この場合、Kが文字列リテラル型であれば[5]そのままKとなり、そうでなければneverになります。

type StrOrNever<K> = K extends string ? K : never;
type A = StrOrNever<'hoge'>; // A = 'hoge'
type B = StrOrNever<123456>; // B = never

そして、Mapped Types[K in keyof Spec]: ...によって先程のConditional TypeをSpecに含まれるすべてのプロパティに適用し、プロパティの値の型として「文字列のキーにはそのキー自身、文字列以外のキーにはnever」が割り当てられたオブジェクトリテラル型を構成します。

type MapStrOrNever<Spec extends StorageCodecSpec> = {
  [K in keyof Spec]: K extends string ? K : never;
};
type Spec = { 
  "foo": Codec<number>,
  "bar": Codec<string>,
  1: Codec<boolean>
};
type M = MapStrOrNever<Spec>; 
// M = { "foo": "foo", "bar": "bar", 1: never }

最後に、先程のMapped Typesの結果(コード例でいうM)から最終結果を得るために、Indexed Access Typesを使います。Indexed Access Typesは、T[K]と書くことでオブジェクトリテラル型TにおけるプロパティKの値の型を取得する仕組みですが、Kとしてunion型を指定すると、それに含まれるすべてのプロパティに対応する値の型をunionで繋いだ型を取得できます。ここでは、Kとしてkeyof Spec(=Specに含まれるすべてのプロパティのunion)を指定しているので、結果はMの「値の型」にあたる部分をすべてunionで繋いだ型になります。

さらに、neverを含むunion型はそこからneverを取り除いたものと等価であり、そのようなunion型からは自動的にneverが除かれます。

// Spec = { "foo": ...  , "bar": ...  , 1: ...   }
// MapStrOrNever<Spec>
//      = { "foo": "foo", "bar": "bar", 1: never }
type StorageKeys = MapStrOrNever<Spec>[keyof Spec];
//               = M["foo" | "bar" | 1]
//               = M["foo"] | M["bar"] | M[1]
//               = "foo" | "bar" | never
//               = "foo" | "bar"

これで、Specに含まれる文字列でないキーを結果から除外できました。ここで説明した、「オブジェクト型のキーのうち何らかの条件を満たすものだけを集めた型を取得する」手法は、この手の実装ではよく使うので、覚えておいて損はないでしょう。

StorageValTypeOf: キーに紐つく値の型を求める型関数

type StorageValTypeOf<
  Spec extends StorageCodecSpec,
  K extends StorageKeys<Spec>
> = Spec[K] extends Codec<infer T> ? T : never;

あるキーに対応する値の型を求める型関数を用意します。これはgetの返り値の型やsetで保存する値の型を、指定したキーから推論するために使います。

考え方は単純で、SpecにおいてキーKに対応する値の型Codec<T>から、なんとかして型パラメータTの部分を抜き出せばOKです。ここでもTypeScriptならではの型の機能を組み合わせています。

まず、Index Accessed TypesSpec[K]を使ってSpecからキーKに対応するCodec<T>型を取得します。そして、条件部にinferを含むConditional Typesを利用して、Codec<T>からTにあたる部分を抜き出します。いわゆる「パターンマッチ」を型に対して行うイメージです[6]

type A = Codec<number> extends Codec<infer T> ? T : never; // A = number
type B = Codec<string[]> extends Codec<infer T> ? T : never; // B = string[]
type N = Promise<number> extends Codec<infer T> ? T : never; // N = never

TypedStorage: 型安全StorageのAPI

interface TypedStorage<Spec extends StorageCodecSpec>{
  get<K extends StorageKeys<Spec>>(key: K): StorageValTypeOf<Spec, K> | null;
  set<K extends StorageKeys<Spec>>(key: K, value: StorageValTypeOf<Spec, K>): void;
  remove(key: StorageKeys<Spec>): void;
};

ここまで用意してきた構成要素を組み合わせれば、理想とする型安全Storageラッパーのインタフェースを構成できます。

getにキーを指定したとき、返り値の型としてそのキーに対応する値の型が推論されるまでの流れを追ってみましょう[7]

  1. getに与えた実引数から、型パラメータKの実際の型が推論される
    • 文字列以外の値やSpecのキーに含まれない文字列を与えた場合、Kの制約StorageKeys<Spec>を満たさないため、型エラーとなる
    • Specのキーに含まれる文字列を与えた場合、Kの推論結果は与えた文字列に対応する文字列リテラル型となる
  2. 決定されたKの型をもとに、StorageValTypeOf<Spec, K>の型が推論される
    • StorageValTypeOfの定義より、結果は与えたキーに対応する紐つく値の型になる

setの場合も同様の流れで引数valueの型が推論されます。

また、removeの引数keyの型については、与えられた値から他の型を推論する必要がないため、型パラメータにする必要はありません。

値レベルの実装

const createTypedStorage = <Spec extends StorageCodecSpec>(
  spec: Spec,
  baseStorage: BaseStorage
): TypedStorageT<Spec> => {
  const keyToCodec = spec;
  const baseStrg = baseStorage;

  // freezeは省略
  return {
    get<K extends StorageKeys<Spec>>(key: K): StorageValTypeOf<Spec, K> | null {
      const rawVal = baseStrg.get(key);
      if (rawVal === null) {
        return null;
      }
      const codec = keyToCodec[key] as Codec<StorageValTypeOf<Spec, K>>;
      return codec.decode(rawVal);
    },
    set<K extends StorageKeys<Spec>>(key: K, value: StorageValTypeOf<Spec, K>): void {
      const codec = keyToCodec[key] as Codec<StorageValTypeOf<Spec, K>>;
      const encoded = codec.encode(value);
      baseStrg.set(key, encoded);
    },
    ...
  }
}

あとは構成したインタフェースを満たす値レベルの実装を書くだけなのですが、ここが意外とつまづきやすい部分になります。TypeScriptコンパイラがこちらの意図を分かってくれない場面(「TypeScriptの敗北[8]」)が往々にして発生し、型エラーに悩まされることになるのです。

この実装でいうと、const codec = keyToCodec[key] as Codec<...>の部分がそれにあたります。この時点では、TypeScriptコンパイラはkeyの型がStorageKeys<Spec>に含まれる型のうちのどれかであるとしか分からないため、keyToCodec[key]の型をCodec<any>と推論するほかありません。しかし、keyToCodec[key]の値が実際に評価されるタイミング(get/setが呼び出された時点)では、定義よりkeyToCodec[key]の型はCodec<StorageValTypeOf<Spec, K>>(Kkeyに対応する文字列リテラル型)に確定するはずです。この事実をコンパイラに伝えるために型アサーションが必要になります。

まとめ

TypeScriptの高度な型システムを活用した、安全で使いやすいAPIの実装例と実装過程を共有しました。この記事が、TypeScriptの型システムを応用した安全で使いやすいAPIの作り方について考えるための一助となれば幸いです。

なお、この記事に登場したTypeScriptの型機能は氷山の一角に過ぎません。今回出てこなかった機能に関して、OSSなどにおける実際の応用例を交えながら解説する記事を今後書くことがあるかもしれません。

ちなみに、この記事で紹介した型安全Storageラッパーの実装はこちらで公開しています…というのをやりたかったのですが、間に合いませんでした^9

脚注
  1. BaseStorage型に関しては、この記事のテーマには深く関与しない部分になりますので解説を割愛します。簡単に説明すると、Storageの内部実装を容易に切り替えられるようにするためのものです。 ↩︎

  2. 値レベルの処理の実装で楽をするためです。 ↩︎

  3. Javaを知っている人向けの説明: Javaのジェネリクスでいう境界なしのワイルドカード<?>のようなものと考えればよいです。 ↩︎

  4. この詳細については十分調べきれていません。後日追記予定です。 ↩︎

  5. 文字列リテラル型のみからなるunion型も適合しますが、ここではMapped Typesと組み合わせているため考慮する必要はありません。 ↩︎

  6. もし「パターンマッチ」と聞いてピンとこないのであれば、正規表現を用いて文字列の一部を抜き出すようなものだと思えばいいでしょう。 ↩︎

  7. TypeScriptコンパイラによる型推論の正確な動作機序を説明するものではないことをご了承ください。 ↩︎

  8. 筆者が敗北している可能性もあります ↩︎

GitHubで編集を提案

Discussion