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
演算子を使って書くとすると、次のようになる:
// 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
と名付ける) に内包して、次の使用例のように書きたい:
// 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
クラスはコンストラクタでハンドリング対象の値を受け取ってインスタンスに内包する:
export class Typeman {
constructor(private readonly value: unknown) {}
}
さて、先に挙げた使用例では、Typeman
インスタンスのメソッドチェイニングの終端にある or
メソッドの戻り値が boolean
型になってほしいので、そこからボトムアップに考えていきたい。
// 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
型以外にも対応できるようにしたいので最終的にはジェネリック型を使う必要があるが、まずは具体的な型を使って考えてみよう:
export class Typeman {
// コンストラクタ省略
or(fallbackValue: boolean): boolean {
// ここで何をする?
}
}
肝心なメソッドの中身だが、先のswitch文と同じ挙動にするためには以下の2つの要件がある:
- メソッドチェインのどこかで
if<型名>
メソッドのコールバックが実行された場合、そのコールバックの戻り値を返す - どのコールバックも実行されなかった場合、引数として渡された
fallbackValue
を返す (default
節に対応)
よって、内包した unknown
値に加えて、コールバックの戻り値もインスタンスプロパティとして追加する必要があることが分かる (resolvedValue
と名付けた):
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
) を返す処理が必要だ:
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
を追加する必要がある:
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
インスタンスを作成するときに最終的にほしい型を型引数に渡さなければならない:
// 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の型推論機能を使って、よりエレガントに書けないだろうか。
結論から言うと、可能である:
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
の値を型推論させている。
これにより、明示的に型を指定する必要はほとんどなくなるはずだ。
ここまでのコード
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する- コールバックで値を受け取るのではなく、メソッドの戻り値として値が欲しい場合に便利
これらの機能の実装手順については、また別の記事で紹介したい。
予告編として、完成版を使って環境変数を型セーフにパースする関数のコード例を載せておこう:
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のインテリセンスがきちんと機能することに注目だ: