[TypeScript] switch 式っぽいものを作成してみる
はじめに
例えば C# における switch 式の様な機能が欲しくなったため、以下の様な switch 式っぽく使える関数を作成してみる。
const value = Math.round(Math.random() * 10); // 0, 1, 2, .., 10
const ordinal = switch2(value)
.case(1, () => "1st")
.case(2, () => "2nd")
.case(3, () => "3rd")
.default(n => `${n}th`)
.return();
console.log(ordinal); // "0th", "1st", "2nd", .., "10th"
ちなみに、類似機能を持ったパッケージは普通に存在する(ので単純に作りたかったので作っただけである)。
作成方針
- method-chaining 形式で条件分岐を記述できる。
- JavaScript の switch 文と同じような感じで記述できる。
- 型推論を活用して、利便性と型安全性を確保する。
型定義の作成
export interface Switch2<T, Result = never, Default = never> {
// 等価性による条件分岐の作成
case<T1 extends T, Result1>(
predicate: T1,
expression: (value: T1) => Result1
): Switch2<T1 extends null | undefined ? Exclude<T, T1> : T, Result | Result1, Default>;
case<Result1>(
predicate: T,
expression: (value: T) => Result1
): Switch2<T, Result | Result1, Default>;
// 任意条件式による分岐の作成
when<T1 extends T, Result1>(
predicate: (value: T) => value is T1,
expression: (value: T1) => Result1
): Switch2<T1 extends null | undefined ? Exclude<T, T1> : T, Result | Result1, Default>;
when<Result1>(
predicate: (value: T) => boolean,
expression: (value: T) => Result1
): Switch2<T, Result | Result1, Default>;
// 既定値の作成
default<Default1>(
expression: (value: T) => Default1
): Switch2<T, Result, [Default] extends [never] ? Default1 : Default>;
// 条件分岐の実行
return(): [Default] extends [never] ? Result | undefined : Result | Default;
return<Default1>(
defaultExpression: (value: T) => Default1
): [Default] extends [never] ? Result | Default1 : Result | Default;
}
-
case
で等価性比較を行いつつ、コールバック形式 1 で一致した時の値を定義する。 -
when
で任意の条件式を使用して分岐を行うようにする。- 比較対象として function が渡される可能性を考慮して
case
とは処理を分ける。
- 比較対象として function が渡される可能性を考慮して
-
default
でいずれの条件にも一致しないときの既定値をコールバック形式で定義する。 -
return
で条件に一致したコールバックを実行して、分岐結果を返す。- いずれの条件にも一致せず、既定値も指定されていない時は
undefined
を返す。 - 実際に使用していると
default
とreturn
を同時に記述することが多いため、return
でも既定値を定義できる機能も持たせる。2
- いずれの条件にも一致せず、既定値も指定されていない時は
機能の作成
class MutableSwitch<T> implements Switch2<T> {
#value: T;
#expression?: (value: T) => unknown;
#defaultExpression?: (value: T) => unknown;
public constructor(value: T) {
this.#value = value;
}
#equal(a: unknown, b: unknown): boolean {
return (a === b) || (Number.isNaN(a) && Number.isNaN(b));
}
public case<Result1>(
predicate: T,
expression: (value: T) => Result1
): Switch2<T, Result1, never> {
if(!this.#expression && this.#equal(this.#value, predicate)) {
this.#expression = expression;
}
return this;
}
public when<Result1>(
predicate: (value: T) => boolean,
expression: (value: T) => Result1
): Switch2<T, Result1, never> {
if(!this.#expression && predicate(this.#value)) {
this.#expression = expression;
}
return this;
}
public default<Default1>(expression: (value: T) => Default1): Switch2<T, never, Default1> {
this.#defaultExpression ??= expression;
return this as Switch2<T, never, Default1>;
}
public return<Default1>(defaultExpression?: (value: T) => Default1): never {
const expression = this.#expression ?? this.#defaultExpression ?? defaultExpression;
const result = expression?.(this.#value);
return result as never;
}
}
export default function switch2<T>(value: T): Switch2<T> {
return new MutableSwitch(value);
}
- 先に作成した型定義通りにクラスを作成する。
- 等価性比較には 同値ゼロ等価性 を使用する。
使用例
冒頭の書き方の他、以下の様にすると結果の型を固定化することもできる。
const value = Math.round(Math.random() * 10);
const ordinal = switch2(value)
.case(1, n => `${n}st` as const)
.case(2, n => `${n}nd` as const)
.case(3, n => `${n}rd` as const)
.return(n => `${n}th` as const); // "1st" | "2nd" | "3rd" | `${number}th`
console.log(ordinal);
おわりに
個人的に使用する分には問題ない使い勝手になったが、以下の様な課題は残っている。
実行速度
以下は適当に処理時間を計測してみた結果である。
┌─────────┬───────────────┬───────┬──────┬────────────────────┬────────────────────┬─────────────────────┬─────────────────────┬────────────────────┬─────────────────────┐
│ (index) │ name │ count │ unit │ total │ average │ median │ minimum │ maximum │ sd │
├─────────┼───────────────┼───────┼──────┼────────────────────┼────────────────────┼─────────────────────┼─────────────────────┼────────────────────┼─────────────────────┤
│ 0 │ 'switch-case' │ 1000 │ 'ns' │ 479.7990322113037 │ 0.4797990322113037 │ 0.3998279571533203 │ 0.2999305725097656 │ 38.900136947631836 │ 1.840725395289892 │
│ 1 │ 'switch2' │ 1000 │ 'ns' │ 2466.6988849639893 │ 2.4666988849639893 │ 1.8999576568603516 │ 0.5998611450195312 │ 106.99987411499023 │ 5.262988448389472 │
└─────────┴───────────────┴───────┴──────┴────────────────────┴────────────────────┴─────────────────────┴─────────────────────┴────────────────────┴─────────────────────┘
通常の switch 文と比較して数倍以上時間を要している。
…と言っても、ナノ秒単位の話なので、大規模データを処理するのでなければそこまで気にすることもないかもしれない。
TypeScript Compiler API を使用して switch 文等に動的置換できれば解決できそうだが、(思い付きのため)実現性は不明。
Mutable
今回はシンプルに mutable なオブジェクトとして機能を作成している。
通常は問題ないが、以下の様な使い方をされた時に結果を想定しにくいという欠点がある。
const sw = switch2(1);
console.log(sw.case(1, () => "first").return()); // "first"
console.log(sw.case(1, () => "1st").return()); // "1st" or "first" ?
これは immutable なオブジェクトにすることで解決はするが、分岐作成毎にオブジェクトを作成することになるため速度に結構な影響が出てしまう。
…しかし、そもそも上記の様な使い方自体がイレギュラーなため、この問題は考慮しなくてもいいかもしれない。
上記の様な書き方ができないような ESLint 等のカスタムルールを作成できればよさそうだが、これも(思い付きのため)実現性は不明。