【TypeScript】プロトタイプ拡張でパイプラインっぽいの作ったり、nullableな値をいい感じに処理したり
やりたいこと
- 関数を左から右に適用したい
- Nullableな値を関数に突っ込むときもいい感じで処理したい
- 括弧地獄はやだ
- そうだ,プロトタイプを拡張しよう
プロトタイプ拡張のコツ.
- むやみに文字列でprototype拡張するとやばいことになりかねないのでシンボルを使う.
- TypeScriptでグローバルの型をいじるには
declare global {}
する.-
declare global {}
はモジュールでないと使えないのでexport {}
する.
-
Object
に [thru]
を定義.TSでは Object
をいじると null, undefined 以外のあらゆる値にプロトタイプ経由で影響が出る.なのでどんな値にでも使える.
const thru = Symbol('tap');
declare global {
interface Object {
[thru]<This, R>(this: This, fn: (arg: This) => R): R
}
}
Object.prototype[thru] = function (fn) {
return fn(this)
}
Object.prototype.foo
を定義すると globalThis
が継承して globalThis.foo
が増える.結果グローバル変数 foo
が生える.
型変数 This
が出てるのは this
の型の決定を呼び出し時まで遅延させるため.これがないと number
型に呼んだときも Object
になってしまう.
const thru = Symbol('tap');
declare global {
interface Object {
[thru]<This, R>(fn: (arg: this) => R): R
}
}
Object.prototype[thru] = function (fn) {
return fn(this)
}
ここで代入するのは function
式でないといけない.アロー関数内での this
は外側のスコープ this
になるため,この場合はうっかり globalThis
を渡してしまうことになる.
interface
側の型定義が合ってれば型変数や引数の型は省略して書ける.合わないときは function
にホバーすると関数のシグネチャがわかる.
foo[thru](func)
は func(foo)
と等価.thru
の名前はLodashから取ったが,[thru]
で1段重ねるごとに6文字ずつ増えていくのがつらい.
const square = (n: number) => n ** 2;
16[thru](square)[thru](console.log);
console.log(square(16)); // 等価
というわけで関数をnullish (null | undefined) 対応させるくんを書いてみた.オーバーロード多すぎ訴訟案件.
const map = <T extends {}, R>(f: (t: T) => R) => {
function r(t: T): R;
function r(t?: null | undefined): undefined;
function r(t: T | null | undefined): R | undefined;
function r(t: T | null | undefined): R | undefined {
return t != null ? f(t) : undefined;
}
return r;
};
引数の型 (から推論される型変数) によって返り値が分岐する,というものなので Conditional Typesを使って T extends null | undefined ? undefined : R
と書きたくなるが,じつは Conditional Types に値を割り当てられるかの判定はめちゃくちゃ厳しく,具体的に言うと Conditional Types が残ってるかぎり無理.既存の型をいじった新しい型 NonNullable<ReturnType<Document["querySelector"]>>
みたいにその場で解決できるなら大丈夫だが,特に関数の返り値に型変数で使うと関数が呼び出されるまで Conditional Types が残るので as
が必須になる.
オーバーロードなら as
がないので安全かというとそうでもなく,実質的には「tが T extends {}
型なら R
を返す (とプログラマは言ってる) よ」なので is
as
と同程度には危ない.
じゃあ Function
にも生やそっか!オブジェクトリテラル型内で (arg: T): R
というの (関数シグネチャ) を書くと関数の型を表現できる.複数連ねて書くとオーバーロードを表現できる,というかオーバーロードを型で書く手段がこれ以外にないと思う.
declare global {
interface Function {
[opt]<This extends {}, R>(this: (t: This) => R): {
(t: T): R;
(t?: null | undefined): undefined;
(t?: T | null | undefined): R | undefined;
}
}
}
Function.prototype[opt] = function () {
return map(this)
}
?.[thru]
でNullishを爆殺する.
declare const maybe: number | null | undefined;
const sqMaybe = maybe?.[thru](square);
[opt]()
で爆殺するパターン.
const sqMaybe0 = square[opt]()(maybe);
[opt]
って2重に関数呼び出ししてるのが余計なので一段薄くした.
declare global {
interface Function {
[opt]: {
<T extends {}, R>(this: (t: T) => R, t: T): R;
<T extends {}, R>(this: (t: T) => R, t?: null | undefined): undefined;
<T extends {}, R>(this: (t: T) => R, t?: T | null | undefined): R | undefined;
}
}
}
Function.prototype[opt] = function <T extends {}, R>(this: (t: T) => R) {
return map(this)()
}
いい感じになった.
const sqMaybe0 = square[opt](maybe);