TypeScriptでany・unknown型の値を型セーフにハンドリングしたい

TypeScriptでコードを書いていて `any` 型に遭遇するたびに禁断の領域に突入している気がしてならないのは僕だけだろうか。
風の便りによると、この禁断の領域だけでプログラムを書く猛者たちがいるようだが……いや、ただの都市伝説だろう。

しかし、TypeScriptでも型セーフのおとぎの国から足を踏み出さなければならないことが往々にしてある。
例えば、APIサーバーからのJSONレスポンスを `JSON.parse` でパースするときや、環境変数の値を取得するとき、スキーマレスNoSQLのレコードを扱うときなどが代表的だろう。
型セーフな世界に戻るためには、 `typeof` 演算子など使って値の型をランタイムに調べる必要があるが、配列かどうかを調べるには `Array.isArray` を使わなければならないとか、 `typeof null === "object"` になるとか、いちいち考えるのことが多いのである。

# ランタイム型チェック処理をクラスに内包したい

例えば、 `any` 型の値を以下の決まりに従って `boolean` 型に変換したいとする:

* `boolean` 型の場合、値をそのまま使う
* `number` 型の場合、値が `0` であれば `false` 、それ以外であれば `true`
* `string` 型の場合、値が `"true"` か `"yes"` であれば `true` 、それ以外であれば `false`
* 上記いずれにも当てはまらない場合 `false`

愚直に `typeof` 演算子を使って書くとすると、次のようになる:

```ts
// valueはany型
const value = JSON.parse(`"yes"`);
// covertedはboolean型 (switch文を式のように使いたいのでIIFEを使っている)
const converted = (() => {
  switch (typeof value) {
    case "boolean":
      return value;
    case "number":
      return value !== 0;
    case "string":
      return value === "true" || value === "yes";
    default:
      return false;
  }
})();
```

これを、 `typeof` 演算子を使う部分をクラス (仮に `Typeman` と名付ける) に内包して、次の使用例のように書きたい:

```ts
// valueはany型
const value = JSON.parse(`"yes"`);
// convertedはboolean型
const converted = new Typeman(value)
  .ifBoolean((value) => value)
  .ifNumber((value) => value !== 0)
  .ifString((value) => value === "true" || value === "yes")
  .or(false);
```

`Typeman` インスタンスに対するメソッドチェイニングのそれぞれが、 `switch` 文の各 `case` と `default` に対応していることが見て取れるだろう。
結果としては同じものの、こちらのほうがより簡潔でかつ `typeof` 演算子の細かい仕様を暗記せずにすむ。
まずはここで使っている `boolean` 、 `number` 、 `string` の3つに焦点を絞り、 `Typeman` クラスをいかに実装するかを考えてみたい。

# `Typeman` クラスの実装

まず、 `Typeman` クラスはコンストラクタでハンドリング対象の値を受け取ってインスタンスに内包する:

```ts
export class Typeman {
  constructor(private readonly value: unknown) {}
}
```

さて、先に挙げた使用例では、`Typeman` インスタンスのメソッドチェイニングの終端にある `or` メソッドの戻り値が `boolean` 型になってほしいので、そこからボトムアップに考えていきたい。

```ts
// convertedがboolean型になるためには...
const converted = new Typeman(value)
  .ifBoolean((value) => value)
  .ifNumber((value) => value !== 0)
  .ifString((value) => value === "true" || value === "yes")
  .or(false); // ...orメソッドの戻り値がboolean型でなければならない
```

もちろん、 `boolean` 型以外にも対応できるようにしたいので最終的にはジェネリック型を使う必要があるが、まずは具体的な型を使って考えてみよう:

```ts
export class Typeman {
  // コンストラクタ省略

  or(fallbackValue: boolean): boolean {
    // ここで何をする？
  }
}
```

肝心なメソッドの中身だが、先のswitch文と同じ挙動にするためには以下の2つの要件がある:

- メソッドチェインのどこかで `if<型名>` メソッドのコールバックが実行された場合、そのコールバックの戻り値を返す
- どのコールバックも実行されなかった場合、引数として渡された `fallbackValue` を返す (`default` 節に対応)

よって、内包した `unknown` 値に加えて、コールバックの戻り値もインスタンスプロパティとして追加する必要があることが分かる (`resolvedValue` と名付けた):

```ts
export class Typeman {
  constructor(
    private readonly value: unknown,
    private readonly resolvedValue?: boolean,
  ) {}

  or(fallbackValue: boolean): boolean {
    return this.resolvedValue === undefined
      ? fallbackValue
      : this.resolvedValue;
  }
}
```

`if<型名>` のコールバックが呼ばれたTypemanインスタンスは `resolvedValue` に `boolean` 型の値が入っていて、呼ばれなかったインスタンスは `undefined` が入っているという仕様だ。

次に、 `if<型名>` メソッドの実装を考えてみよう。
メソッドチェイニングを実現するには、メソッドの戻り値は `Typeman` インスタンスでなければならない。具体的には、コールバックが呼ばれた場合はその戻り値を使って新しく `Typeman` インスタンスを作って返し、それ以外の場合は自分自身 (`this`) を返す処理が必要だ:

```ts
export class Typeman {
  // コンストラクタ省略

  ifBoolean(callback: (value: boolean) => boolean): Typeman {
    if (typeof this.value === "boolean") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  ifNumber(callback: (value: number) => boolean): Typeman {
    if (typeof this.value === "number") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  ifString(callback: (value: string) => boolean): Typeman {
    if (typeof this.value === "string") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  // orメソッド省略
}
```

実はこれだけで、すでに先の使用例にあるコードはコンパイルが通り、期待通りの実行結果となる。
次は、 `boolean` 型だけでなく、どんな型にも対応できるようにしてみよう。まず、 `resolvedValue` プロパティを `boolean` 型からジェネリック型 `T` に変えるため、 `Typeman` クラスに型引数 `T` を追加する必要がある:

```ts
export class Typeman<T> {
  constructor(
    private readonly value: unknown,
    private readonly resolvedValue?: T,
  ) {}

  ifBoolean(callback: (value: boolean) => T): Typeman<T> {
    if (typeof this.value === "boolean") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  // isNumber, isStringメソッド省略

  or(fallbackValue: T): T {
    // この実装では、 `resolvedValue` が `undefined` の場合 
    // (コールバックの戻り値が `undefined` だった場合)
    // でも `fallbackValue` を返してしまうというバグがある。
    // 解決方法の一つとして、 `resolvedValue` の型を `[T] | []` とし、
    // コールバックが呼ばれていないことを空の配列で表現する方法がある。
    // この場合、コールバックの戻り値が `undefined` の場合 `[undefined]` 、
    // コールバックが呼ばれていない場合 `[]` となるので区別がつく。
    // ただし、ここでは実装の簡略化のためバグを残したままにする。
    return this.resolvedValue === undefined
      ? fallbackValue
      : this.resolvedValue;
  }
}
```

ただし、この実装では `Typeman` インスタンスを作成するときに最終的にほしい型を型引数に渡さなければならない:

```ts
// valueはany型
const value = JSON.parse(`"yes"`);
// convertedはunknown型 (T = unknownのデフォルトが使われてしまう)
const converted = new Typeman(value).ifBoolean((value) => value).or(false);
// converted2はboolean型
const converted2 = new Typeman<boolean>(value).ifBoolean((value) => value).or(false);
```

TypeScriptの型推論機能を使って、よりエレガントに書けないだろうか。
結論から言うと、可能である:

```ts
export class Typeman<T = never> {
  constructor(
    private readonly value: unknown,
    private readonly resolvedValue?: T
  ) {}

  ifBoolean<R>(callback: (value: boolean) => R): Typeman<T | R> {
    if (typeof this.value === "boolean") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  // isNumber, isStringメソッド省略

  or<R>(fallbackValue: R): T | R {
    return this.resolvedValue === undefined
      ? fallbackValue
      : this.resolvedValue;
  }
}
```

それぞれのメソッドにも型引数 `R` を追加し、メソッド呼び出し時に渡した関数式の戻り値から `R` の値を型推論させている。
これにより、明示的に型を指定する必要はほとんどなくなるはずだ。

# ここまでのコード

```ts
export class Typeman<T = never> {
  constructor(
    private readonly value: unknown,
    private readonly resolvedValue?: T
  ) {}

  ifBoolean<R>(callback: (value: boolean) => R): Typeman<T | R> {
    if (typeof this.value === "boolean") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  ifNumber<R>(callback: (value: number) => R): Typeman<T | R> {
    if (typeof this.value === "number") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  ifString<R>(callback: (value: string) => R): Typeman<T | R> {
    if (typeof this.value === "string") {
      const resolvedValue = callback(this.value);
      return new Typeman(this.value, resolvedValue);
    }
    return this;
  }

  or<R>(fallbackValue: R): T | R {
    return this.resolvedValue === undefined
      ? fallbackValue
      : this.resolvedValue;
  }
}
```

# さらに欲しい機能

これだけではまだ実用性に乏しいので、以下の機能を追加したい:

- コールバックの戻り値が `undefined` になる場合を正しくハンドリングする
- 複数の `if*` メソッドにマッチする場合、 `if` や `switch` に倣って最初の1つのみコールバックを呼ぶ
- `ifArray` メソッド: 配列のハンドリングをする
- `ifObject` メソッド: オブジェクトのハンドリングをする
- `ifEquals` メソッド: 引数として受け取った値と `===` が成り立つ場合のみコールバックを呼ぶ
- `ifInstance` メソッド: コンストラクタを引数ととして受け取り、 `instanceof` 演算子を使ってそのコンストラクタで作られたインスタンスかを判定、その場合のみコールバックを呼ぶ
- `orElse` メソッド: `or` でデフォルト値を直接指定する代わりに関数の戻り値を使う
- `orThrow` メソッド: マッチしなかったときに例外を `throw` する
- `orMarkError` メソッド: マッチしなかったときにエラーを記憶しておき、あとでまとめて `throw` する
    - JSONリクエストのバリデーションで一度に2つ以上のエラーを返したいときに便利
- `unwrap*` メソッド: 条件にマッチする場合は内包された値を戻り値として返し、マッチしない場合は例外をthrowする
    - コールバックで値を受け取るのではなく、メソッドの戻り値として値が欲しい場合に便利

これらの機能の実装手順については、また別の記事で紹介したい。

予告編として、完成版を使って環境変数を型セーフにパースする関数のコード例を載せておこう:

```ts
export const loadConfigFromEnv = () =>
  new Typeman(process.env)
    .ifObject((env) => ({
      aws: {
        accessKeyId: env.get("AWS_ACCESS_KEY_ID").unwrapString(),
        secretAccessKey: env.get("AWS_SECRET_ACCESS_KEY").unwrapString(),
      },
      exampleApi: {
        useMock: env
          .get("EXAMPLE_API_USE_MOCK")
          .ifString((value) => value.toLowerCase() === "true")
          .orThrow(),
        endpoint: env.get("EXAMPLE_API_ENDPOINT").unwrapString(),
      },
    }))
    .orThrow();
```

関数の戻り値の型はTypeScriptが型推論してくれるので、コンパイラの型チェックやIDEのインテリセンスがきちんと機能することに注目だ:
![code-with-typing.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/305052/5244d18f-2e14-d0f1-bc41-40d649375524.png)