スプーキーズのちょっとTech。

SPOOKIES社内のより技工的な、専門的なブログページです。

「Type」ScriptでHelloWorldをしてみた Part1

やあこんにちは。🌱だよ。皆さんはこのポストをご覧になったでしょうか?

この投稿の画像、頭で解けた人はいますでしょうか? 実はこれ、最終的には「Hello_Type_World」という文字が推論されるんです! すごいでしょ

今回は上のコードが主に題材となっています!

TypeScriptには俗に言われている「型パズル」というのが存在しています。 が、今回は特別に...!? 「型パズル」ではなく「型プログラミング」をしてみたいと思います。つまり型だけでプログラミングをするんです。処理は書いちゃいけません!

やはり型プログラミングをするうえで一番最初にするのは誰しもやってきた「Hello World」の出力でしょう。 ですが、今回は「型」が題材なので、「出力」ではなく「推論」をしていきます。 もちろん文字列で単にHellow Worldをするのではなく、TypeScriptの「Type」をフルに使った正真正銘の「Type」ScriptでHello Worldをやっていこうと思います!!!!

結果だけ知りたい方のために最終的にはこのコードはこちらです!

type Result = TupleJoin<[
  Capitalize<TupleJoin<[h, e, l, l, o]>>,
  sp,
  Capitalize<TupleJoin<[w, o, r, l, d]>>,
  ex
]>;

ね、簡単でしょう?

え...難しい?じゃあ、順を追ってちゃんと解説していきます!

実行環境

  • TypeScript v5.9.3
  • Target/Module: ESNext
  • TypeScript Playground 標準設定

ルール

  • typeのみ定義可能(つまり関数/値の定義禁止)
  • 文字テーブル以外のlength > 0の直接的な文字リテラルの禁止
  • テンプレートリテラルにおいてプレースホルダー以外の使用を禁止
  • 直接的な数値リテラルの禁止
  • 型定義時のジェネリクス引数のextends及びデフォルト値の禁止
    • type Type<T extends string> みたいな表現ができない代わりにtype Type<T> = T extends stringにしてねという意味
  • object型({ key: value })の定義禁止
  • 文字テーブルを使用し、Resultが"Hello World!"という文字リテラルで推論されていること

説明

1. "length"を作る

文字リテラルが作れない以上"length"は作れないです。 なんで"length"を作らなきゃいけないのか?というと、これはTuple型系の推論をする際に「length」というのはとても重要になります。

type TupleLEngth = [a, b, c, d]["length"];

この場合、TupleLengthは4の数値リテラルを返してくれます。これはTuple型というTypeScriptにある特殊な型形態だからこそできる推論で、基本的にArray.prototype.lengthは静的に要素数を推論できないためnumberを推論しますが、TypeScriptのTuple型は「要素数が必ず固定になっている配列型」という解釈をするため、必ずlengthが一定になる=lengthが数値リテラルで推論できるわけです。

そしてこの推論をするために"length"が必要となるわけですね。

1-1. 標準APIから文字リテラルを抽出する!

しょっぱなから何言ってる変わらないと思いますが、標準APIは文字リテラルの宝庫です。 つまるところ標準APIのプロパティ名や関数名を抽出できれば文字リテラルを定義することなく手文字リテラルを作れちゃうわけですね。

主にここでキーとなってくるのはこの構文です。

type Key = keyof { aaa: 0, bbb: 1 }; // 'aaa' | 'bbb'

keyofはそのオブジェクトのプロパティ名としてアクセス可能なものを文字リテラルのUnionとして返してくれます。 つまりこれで文字リテラルが手に入るわけですね。すばらしい

ただちょっと待って!これはUnionです。つまり完全にその文字リテラルを保証してくれるわけではありません。 つまりUnionじゃなければいいんです。 じゃあどうすればいいのか?オブジェクトを定義する?でも今回のルールでは新規オブジェクトの定義はできません。 オブジェクトからそのプロパティだけを抽出しちゃいましょう!

type IsAny<T> = true extends false & T ? true : false;
type Equal<L, R> = [R] extends [L] ? [L] extends [R] ? true : false : false;
type PickKey<T, P> = {
  [K in keyof T]: IsAny<T[K]> extends false
    ? Equal<T[K], P> extends true
      ? K
      : never
  : never
}[keyof T];

やってることを端的にいうと、オブジェクト内にあるプロパティの型が指定された型に一致するプロパティの値をプロパティ名として、オブジェクトのキーから値(プロパティ名)だけを取り出すということをしています。

例えば、PickKey<typeof Object, typeof Object.defineProperty>とすると"defineProperty"が返ってきます これはtypeof ObjectObjectConstructorを型として、typeof Object.defineProperty<T>(o: T, p: PropertyKey, attributes: PropertyDescriptor & ThisType<any>) => Tなので、この型に合って(Equal)かつAnyではないプロパティを探します。

型のEqualって何ぞやって感じですが、B extends Aは数学の集合的な挙動をして、集合記号的にいうとB⊆Aなので、プログラム的に直すとB <= Aです。つまりその逆であるB >= Aをすると、B <= A && B >= Aで、B == Aと同じような意味合いを持つことができます。 しかし、どちらかがUnionである場合注意が必要で、TypeScriptではextendsを通るときにUnion分散というのが行われます。例えばEqual<A | B, A>とすると推論されるのはbooleanとなります。(A==Aであり、A==Bでもあるため、true | falseとなり、結果booleanと解釈される) それを防ぐには、Tuple型に内包して定義をすると防げます。なので、[R] extends [L] ? [L] extends [R]となっているわけですね。

あと、IsAnyで防いでいるのは単にAnyが特殊だからです。 anyというのはTypeScriptにおいては絶対王者と言わんばかりの強制力を持ちます。

type A = 0 & any; // any
type B = 0 & unknown; // 0

anyunknownと違い、「推論を放棄する」というのに近い意味を持つもので、放棄された推論は基本的に何してもanyとなるという挙動をします。 なのでextendsを通しても基本的には必ずtrueになるような挙動を示すため、それを逆手にとってtrue extends false & Tと置くと、any以外は基本的にfalseになります。なのでいい感じにAnyかどうかを判別できるわけです。

1-2. プリミティブから文字リテラルを抽出する!

特殊なプリミティブ型はテンプレートリテラルに通すと文字リテラルを取得できます。 主にtrue, false, null, undefinedの4つは文字リテラルとして推論してくれます。

type True = `${true}`; // "true"
type False = `${false}`; // "false"
type Null = `${null}`; // "null"
type Undefined = `${undefined}`; // "undefined"

1-3. 最初の文字と最後の文字を抜き出す!

さて、ここまで来たら文字を加工する工程になります。 いろんなところから文字リテラルは推論できましたが、lengthを作るためにはちゃんと"length"という文字リテラルを生み出さなくてはなりません。

なので、推論した文字リテラルの一部を拝借できるようにしましょう!

type GetFirstChar<S> = S extends `${infer First}${string}`
  ? First
  : never;
type GetLastChar<S> = S extends `${infer First}${infer Remain}`
  ? Remain extends ''
    ? First
    : GetLastChar<Remain>
  : never;

実はテンプレートリテラルはextendsとinferを使うと面白いことができて、特定の文字リテラルの一部をinferで使用できちゃうんですね~

なので、${infer First}${string}とテンプレートリテラルに入れると、Firstは最初の1文字、その他は何かしらの文字列(string)というextendsができて、Firstを返してあげると最初の1文字だけ返ってくるわけです。

type A = GetFirstChar<"First">; // "F"

最後の文字を取得したいときは、型を再帰的に推論してあげるとできるようになります。 「最初の文字を取り出す」という動作で残った文字列リテラルが、もし空文字列であるならば「それ以上文字がない」ということになるので、取り出した文字を返します。まだ文字が存在するなら、残った文字リテラルで同じ処理を繰り返すといった動作をすることで、結果的に最後の文字が取り出せるというわけです。

// 関数でいうとこういう感じになる
function GetLastChar(S: string) {
  const First = S[0];
  const Remain = S.slice(1);
  if (First !== undefined && Remain !== undefined) {
    if (Remain === "") {
      return First;
    } else {
       return GetLastChar(Remain);
    }
  } else {
    throw;
  }
}

なぜこんな推論ができるかというと、テンプレートリテラルには特殊な推論ルールがあって、以下のルールで推論されるからです。

各型式(infer Tstring)を推論元となる文字列内の部分文字列と左から右へ照合することで進行される。型式の直後にリテラル文字列が続く場合、推論元文字列内でそのリテラル文字列が最初に現れる位置まで、推論元文字列から0個以上の文字を推論することで照合される。 ただし、「型式の直後に別の型式が続く場合、推論元文字列から単一の文字を推論することで照合される。」

引用元: https://stackoverflow.com/questions/76112923/how-do-i-correctly-use-multiple-instances-of-the-infer-keyword-with-template-lit

つまり、

type ContainsUnderFirst<S> =
  S extends `${infer First}_${string}` ? First : never;
type A = ContainsUnderFirst<"_">; // A = ""
type A = ContainsUnderFirst<"aaa_">; // A = "aaa"
type A = ContainsUnderFirst<"_aaa">; // A = ""

みたいな感じになるということです。

1-4. 結合関数を作って1つの文字リテラルを生み出す!

文字リテラルの文字を抽出で来たら結合をして1つの文字リテラルとして生み出しましょう! つまりjoinを作るというわけですね。

type TupleJoin<Tuple> =
  Tuple extends [infer Str extends string]
    ? Str
    : Tuple extends [
        infer Char extends string,
        ...infer Remain
    ]
      ? `${Char}${TupleJoin<Remain>}`
      : never;

これも再帰的にTupleを取り出していって1文字づつ文字リテラルにして言っているだけです。

1. ["a", "b", "c"] // 3. a + bc
2. ["b", "c"] // 2. b + c
3. ["c"] // 1. c

これもTupleとinferで推論をしていて、Tupleでは、通常の配列同様にスプレッド構文が使用できちゃいます。 なので、[infer T, ...infer U]みたいにすると、Tには最初の要素が、Uにはそれ以外の要素が入るような推論をしてくれます。 なのでこのやり方で再帰的に行っていくと最初の要素がどんどんなくなっていくような挙動になるので、要素がなくなったときに文字列を左から埋めていくと、まるでjoinのような挙動を再現できちゃうんですね。

1-5 最後にlengthを錬成する!

さあここまで来たらあとは作った型を元に作るだけ!

type Length =
  TupleJoin<[
    GetLastChar<ToString<null>>,
    GetLastChar<ToString<true>>,
    GetFirstChar<ToString<null>>,
    GetFirstChar<PickKey<typeof globalThis, typeof globalThis>>,
    GetFirstChar<ToString<true>>,
    GetLastChar<PickKey<typeof globalThis, Math>>,
  ]>;

nullの「l」、trueの「e」、nullの「n」、globalThisの「g」、trueの「t」、Mathの「h」 これらを掛け合わせると...念願の"length"が完成!!!!!!

なげぇ!あとめっちゃ無理やり!!!

part2へ...

結構長かったですね。 でも大丈夫です!ここまでのことが理解できていればあとはもう同じような要素を組み合わせていくだけなので、実質ウィニングランみたいな感じです。

次は「「Type」ScriptでHelloWorldをしてみた Part2」でお会いしましょう。