Typescriptを過信していた

この記事は, OPTIMIND x Acompany Advent Calendar 2021 11日目 の記事です.

ほぼアドカレしか書いてないブログなのにアドカレだけで特定できる情報が集まってしまい悲しいです.

はじめに

Typescriptは素晴らしい言語です.
良い点を挙げればキリがないですが,まず型がちゃんとあるというだけで革命的で,今までだらだらとJavascriptを書いていた自分にとってはもう...あれです.
そんなこんなで書けもしないTypescriptを崇拝していた私ですが,ある時この盲目的な信仰を覚まさせるような出来事に直面しました.
本記事では,実際に直面した出来事,それに対して私が行った解決策について記していこうと思います.

Typescriptでオーバーロード

本題に入る前にTypescriptのオーバーロードについて説明します.

Typescriptのオーバーロードは次のように実現します.

const f = (x: any) => {
  if (typeof x === "number") {
    return "number";
  }
  if (typeof x === "string") {
    return "string";
  }
  return "None";
}

CやJava等とは違い,言語レベルで分岐することができないため,typeof演算子を用いて自分で処理を分岐する必要があります.これでは引数や返り値の型がanyとなってしまい,型安全でなくなります.しかし,Typescriptでは次のように型情報を付与することで型安全なオーバーロードを実現できます.

type TypeF = {
  (x: number): number;
  (x: string): string;
};
const f: TypeF = (x: any) => {
  /* 略 */
};

一見any型を受け取りany型を返す関数に見えますが,TypeF2で定義された型情報に基づいて型チェックを行ってくれるので安全です.
実際に次のコードにより型チェックを確認することができます.

const n1: number = f(10);
const s1: string = f("str");

const s2: string = f(10); // f(number)の帰り値はnumberなのでERROR
const n2: number = f("str"); // f(string)の帰り値はstringなのでERROR

const a1: any = f(); // 引数なしは定義にないのでERROR
const a2: any = f(true); // boolを引数で受け取る定義はないのでERROR

なお,以下では簡単のため型情報の定義はせずにオーバーロードのコードを記述していきます.実際に書く場合は型情報の記述を忘れないようにしましょう.

もっとオーバーロード

さて,Typescriptは安全にオーバーロードできることが分かったので,もっと試してみましょう.
次のコードでクラス型のオーバーロードをしてみます.

const f = (x: any): string => {
  if (typeof x === "A") {
    return "x is Class A";
  }
  return "x is not Class A";
}

実行すると(私の環境では)下記のエラーが出ました.

error TS2367: This condition will always return 'false' since the types '"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"' and '"A"' have no overlap.
5   if (typeof x === "A") {

要約すると,typeof演算子で得られるのは記載されている8種類のみであり,条件分岐は常にfalseになるとのことです.あれ?つまりオーバーロードってこの8種類でしかできなくないですか?
試しにクラス型に対してtypeof演算子を適用して出力してみます.

class A {};
class B {};

const main = () => {
  const a: A = new A();
  const b: B = new B();

  console.log(typeof a);
  console.log(typeof b);
};
main();
object
object

どちらもobjectと出ています.これでは判別ができません.
型安全であるはずのTypescriptがどうしてこんな簡単なこともできないのでしょうか.それはTypescriptが結局Javascriptへと変換されるからです.Typescriptでどんなコードを書こうとも実行時には全てJavascriptへと変換されるため,結局Javascriptでできることしかできません.それは型についても同様で,Typescriptでどれだけ型を識別しようとJavascriptには関係ないのです.
そうは言ってもオーバーロードはしたいです.そこで,ちょっとしたテクニックを使ってオーバーロードを実現します.

クラス型の識別

これまでオーバーロードtypyofによって行うとしていましたが,自分で分岐を定義することを考えると区別さえできればkeyは何でも良さそうです.Typescriptではinstanceofによってインスタンスの型を確認することができるので,次のようにしてオーバーロードできます.

class A {};
class B {};

const f = (x: any): void => {
  if (x instanceof A) { console.log("x is A"); }
  if (x instanceof B) { console.log("x is B"); }
}

標準の型の識別

関数型や配列型も何も考えずinstanceofで識別できます.

const f = (x: any): void => {
  if (x instanceof Function) {console.log("x is Function");}
  if (x instanceof Array) {console.log("x is Array");}
}

ユーザ定義型の識別

Typescriptでは構造体のような自作の型を定義できます.値がこの自作型に沿っているかどうかはinstanceofでは確認することができません.そこで,強引にその型にキャストして構成要素を取り出してみて,それが定義されているかどうかを確認する,という手法で識別します.
これは次のようにして実現します.ええんかこれ.

type A = { a: number };
type B = { b: number };

const f = (x: any): void => {
  if ((x as A).a !== undefined) { console.log("x is A"); }
  if ((x as B).b !== undefined) { console.log("x is B"); }
}

もっともっとオーバーロード

あらゆるオーバーロードができたかに見えますがまだ足りません.前節でArray型のオーバーロードを行いましたが,これは格納されている型に関わらず全てArrayであるため識別できていません.こういったかゆい所にも手を届かせましょう.

Arrayの次元によるオーバロード

ここまで来ると標準の仕組みだけではオーバーロードを実現することは困難です.ですが,やってることは結局型を識別して条件分岐するだけなので,自分で型を識別するコードを書けばなんでも解決です.
Arrayは以下のように次元数を取得する関数を作成すれば容易にオーバーロードできます.

const getDim: (a:any) => number = (a: any) => {
  if (a instanceof Array) { return 1+getDim(a[0]); }
  return 0;
}

const f = (x: any): void => {
  if(getDim(x) == 1){ console.log("x's dim is 1"); }
  if(getDim(x) == 2){ console.log("x's dim is 2"); }
  if(getDim(x) == 3){ console.log("x's dim is 3"); }
}

Arrayの型によるオーバロード

次元数で判別ができるようになったので,次は型でも識別できるようにします.次元数のオーバーロードにて値を再帰的に辿っていくことでジェネリクスの要素にいい感じにアクセスできることが分かりました.これを応用し,識別keyを文字列としていい感じに表現します.実際の実装は以下になります.

const getTypes: (a:any) => string = (a: any) => {
  if (a instanceof Array) { return "Array<"+getTypes(a[0])+">"; }
  return typeof a;
}

const f = (x: any): void => {
  if(getTypes(x) === "Array<number>"){
    console.log("x's type is Array<number>"); 
  }
  if(getTypes(x) === "Array<Array<number>>"){
    console.log("x's type is Array<Array<number>>"); 
  }
  if(getTypes(x) === "Array<Array<Array<number>>>"){ 
    console.log("x's type is Array<Array<Array<number>>>"); 
  }
}

ジェネリクスのオーバロード

もうお分かりですね?

class A<T> {
  t: T;
  constructor(t: T) {this.t = t;}
}
class B<T> {
  t: T;
  constructor(t: T) {this.t = t;}
}

const getTypes: (a:any) => string = (a: any) => {
  if (a instanceof Array) { return "Array<"+getTypes(a[0])+">"; }
  if (a instanceof A) { return "A<"+getTypes(a.t)+">"; }
  if (a instanceof B) { return "B<"+getTypes(a.t)+">"; }
  return typeof a;
}

const f = (x: any): void => {
  if(getTypes(x) === "A<number>"){
    console.log("x's type is Array<number>"); 
  }
  if(getTypes(x) === "B<number>"){
    console.log("x's type is B<number>"); 
  }
  if(getTypes(x) === "A<B<number>>"){
    console.log("x's type is A<B<number>>"); 
  }
  if(getTypes(x) === "Array<A<Array<B<number>>>>"){
    console.log("x's type is Array<A<Array<B<number>>>>"); 
  }
}

もっともっともっとオーバーロード

今までは型がある前提で話を進めてきました.ですがTypescriptを扱う上でこんなことはありませんか?
「ファイルからjsonを読み込む.ただし,jsonの形式は決まっておらず事前に型定義ができない.」
この型のないjsonオーバーロードしたくないですか?します.

jsonオーバーロード

Typescriptでは読み込んだ文字列をjsonとして扱う場合JSON.parseによりjsonに変換します.このJSON.parseは中でいい感じに色々してくれる素晴らしい関数なので,この関数でparseできない文字列はただのstringです.つまり,以下のようにtryを使用することで分岐できます.

const f = (x: string): void => {
  try{
    JSON.parse(x);
    console.log("x is json")
  }catch(e){
    console.log("x is string");
  }
}

無をオーバーロード

前章にてArrayのオーバーロードが実現できると言いました.しかし,次に示す空の配列はオーバーロードできません.

const a: Array<number> = [];
const b: Array<Array<number>> = [[]];

これらをオーバーロードするにはどうしたら良いでしょうか?
結論これはできません.この事実は変換されたJavascriptコードを見ると自明に理解できます.
以下に実際に変換されたコードを示します.見ると,Typescriptであったnumberという情報がなくなっていることが分かります.さらに,今までは消えた型情報を値から強引に取り出していたのですが,そもそも値がないためそれができません.結局Javascriptになってしまうが故にどうしようもないのです.

const a = [];
const b = [[]];

おわりに

Typescriptを信じていたのに全然オーバーロードできない上に強引にやれば実現できてしまうって便利でも安全でもないと感じてしまいました.Typescriptは所詮Javascript!w

注意:この記事を見て参考になったと思う人がいたら考え直してください.強引なオーバーロードを書く前に設計を見直しましょう.