Open19

【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)); // 等価
おーみーおーみー

アンダースコアにするとまあなんとなく演算子っぽい見た目になった.Lodashと衝突しそう.

16[_](square)[_](console.log);
おーみーおーみー

もはやシンボルではないがグローバル変数が増えるわけではないのでワンチャンある.

16["|>"](square)["|>"](console.log);
おーみーおーみー

というわけで関数を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;
    }
  }
}
おーみーおーみー

?.[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)()
}