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のインテリセンスがきちんと機能することに注目だ:
