Upgrade to Pro — share decks privately, control downloads, hide ads and more …

明日からも業務で使えない!TypeScript の型レベル文字列を Base64 エンコーディング

Avatar for k2 k2
May 14, 2026

明日からも業務で使えない!TypeScript の型レベル文字列を Base64 エンコーディング

Spring LT 2026

Avatar for k2

k2

May 14, 2026

Other Decks in Programming

Transcript

  1. さっそく成果物 この魔法の型を付けてあげると… TypeScript のコンパイル時に、型として Base64 文字列が計算される! 1 let a =

    btoa("Hello, World!"); // a: string 1 declare global { 2 function btoa<T extends string>(data: T): Base64Encode<T>; 3 } 2 / 23
  2. Base64 3 バイトを 4 文字に変換するエンコーディング バイナリデータを安全な範囲の文字列として扱うなどで用いられる 6 ビットで割り切れない余りは 0 を詰めて補完

    出力文字列の長さが 4 の倍数になるよう = で埋める 1 H e l 2 01001000 01100101 01101100 ← 24 ビット 3 ↓ 4 ↓ 6 ビットずつ区切って変換 5 ↓ 6 010010 000110 010101 101100 7 S G V s ` ` 5 / 23
  3. 実装のパイプライン CharToBits で各文字を 8 ビット文字列へ変換する 連結したビット列を EncBits で 6 ビットずつ

    Base64 文字へ変換する 余りビットやパディング、folding など 「型で」 「素直に」実装します 今回の前提: 今回の入力文字列は ASCII 範囲(0x00〜0x7F)に限定 1 入力文字列 "Hel" 2 ↓ 3 <CharToBits> 文字 → 8 ビット文字列 4 ↓ "H" → "01001000" など 5 ↓ 結合 6 "010010000110010101101100" 7 ↓ 8 <EncBits> 6 ビットずつ分割して B64 文字へ 9 ↓ 6 ビットずつ処理 10 ↓ 変換 "010010" + 変換 "000110" + ... 11 "S" + "G" + ... 12 ↓ 13 出力文字列 "SGVs" 6 / 23
  4. ① CharToBits — 文字をビット文字列へ 文字とビット列の対応を 型のマップ で持つ CharToBits["H"] のように、オブジェクトのプロパティアクセスのように型を参照できる 応用として、配列型であれば

    T["length"] で number が取れるなど タプルであれば T["length"] は数値リテラル型を返したりする こういうのは気合いで実装するのが最短 泥臭くやっていく コーヒー飲みながら優雅に、なんてことはありません ` ` 1 // 文字 → 8 ビット文字列のルックアップテーブル 2 type CharToBits = { 3 "H": "01001000"; 4 "e": "01100101"; 5 // ... 128 エントリ 6 }; ` ` ` ` ` ` ` ` 8 / 23
  5. ② EncBits — 6ビットずつ Base64 へ テンプレートリテラルの パターンマッチ で先頭 6

    ビットを処理して再帰 T extends (infer U) という形でパターンマッチのようなことができる ① 文字列に対して、1 文字ずつとってくるパターンマッチ ② ABC... が extends Bit6 な型であり、それを Bits にバインドするというパターンマッチ BitsToB64C[Bits] も先程と同様の型のマップが定義してある ` ` 1 // ビット列 → Base64 文字列 2 // "010010000110..." → "SG..." 3 type EncBits<S extends string> 4 = S extends `${infer A}${infer B}${infer C}${infer D}${infer E}${infer F}${infer Rest}` // ① 5 ? `${A}${B}${C}${D}${E}${F}` extends (infer Bits extends Bit6) // ② 6 ? `${BitsToB64C[Bits]}${EncBits<Rest>}` 7 : never 8 : ""; ` ` ` ` ` ` ` ` ` ` 9 / 23
  6. ③ Enc{3,2,1}Str — 変換+余りの処理 3 文字 (24 bit) に対して 6

    bit ごとに処理すると 6, 12, 18, 24 1 文字は 8 bit なので、渡ってくる bit 数は 8, 16, 24 6 bit + (2 bit << 2) + == 6 bit + 6 bit + (4 bit << 1) + = 6 bit + 6 bit + 6 bit = が 3 つの場合は存在しません ` ` 1 type Enc3Str<A extends Char, B extends Char, C extends Char> 2 = EncBits<`${CharToBits[A]}${CharToBits[B]}${CharToBits[C]}`>; 3 4 type Enc2Str<A extends Char, B extends Char> 5 = `${EncBits<`${CharToBits[A]}${CharToBits[B]}00`>}=`; 6 7 type Enc1Str<A extends Char> 8 = `${EncBits<`${CharToBits[A]}0000`>}==`; ` ` ` ` ` ` 10 / 23
  7. ④ EncodeToString — 再帰的に変換 ① 3 文字ずつ処理 8 と 6

    の最小公倍数を取って 24 bit ずつ操作する ② 末尾は 2 文字 / 1 文字ケースに分岐して最終文字列を組み立てる never never never は TS 型プログラミングの発作みたいなものなので気にせず… ` ` 1 type EncodeToString<T extends string> 2 = T extends "" 3 ? "" 4 : T extends `${infer A}${infer B}${infer C}${infer Rest}` // ① 5 ? A extends Char ? B extends Char ? C extends Char 6 ? Join<Enc3Str<A, B, C>, EncodeToString<Rest>> 7 : never : never : never 8 : T extends `${infer A}${infer B}` // ② 9 ? A extends Char 10 ? B extends Char 11 ? Enc2Str<A, B> 12 : B extends "" 13 ? Enc1Str<A> 14 : never : never : never; 11 / 23
  8. エントリポイント ① T が string 型(非リテラル)なら:計算をあきらめて string を返す ② エンコードが

    never になったら: string にフォールバック ③ リテラルで計算できたとき:具体的な Base64 文字列を返す ポイント:実行の順序を追うなら逆順に読む 関数 (のような型) の呼び出しは Base64Encode → EncodeToString → EncNStr ... 1 type Base64Encode<T extends string> 2 = string extends T // ① 3 ? string 4 : EncodeToString<T> extends never // ② 5 ? string 6 : EncodeToString<T>; // ③ ` ` ` ` ` ` ` ` ` ` ` ` 12 / 23
  9. 限界② 実際の入力は string 型 btoa に渡す引数の実行時の型は基本的に string 型 リテラル型が使えるのは 定数

    のみ 実際のユースケースでは型計算が有効なことはない プログラム記述の外部からやってくることが多い TypeScript の型はコンパイル後には消える 完全に値の世界と型の世界が切り離されている 実行時にしか定まらない値に対しての保証は当然ない そもそも、実際に base64 を使う場面で問題になるのは型ではない ` ` 1 function encode(input: string) { 2 const result = btoa(input); // string ← リテラルではないので計算されない 3 } ` ` ` ` 1 btoa("Hello") // ✅ "SGVsbG8=" 2 btoa(someVariable) // ➡ string ( 諦め) 17 / 23
  10. 限界③ TypeScript の型レベルロジック自体は 検証されない TypeScript の型は「実行前に気付かせる道具」であるはず 一方、型レベル計算の誤りは TypeScript の型計算を実行しないと分からない 型安全を実現するために型安全ではないロジックを書いている

    型安全な型レベルプログラミング? 型の型というような概念が必要だが、そういったものはない 1 // 型検査を走らせる前にこれが string のサブタイプということを示す方法は? 2 type Enc3Str< 3 A extends Char, 4 B extends Char, 5 C extends Char 6 > = EncBits<`${CharToBits[A]}${CharToBits[B]}${CharToBits[C]}`>; 1 // こういうものは書けない 2 type Higher<F, T> = F<T> 18 / 23
  11. 道具の限界とエンジニアリング 「何ができて」 「何ができないか」 TypeScript の型システムは値レベルの操作に対して意味付けしていくもの 実行時の値に依存する部分は実行時に任せる 型は実行時の関数呼び出しやロジックに対して保証できる範囲しかシステムにならない 「やること」 「やらないこと」 Base64

    の計算結果を型に付けても仕方ない 実際は大規模データのメモリ効率の良い操作などを詰めるべき 「何ができる」 「何ができない」を知ることで線引きができる 道具を使うべき場所、問題に対して使えそうな道具の引き出しは大事 ex. ストリーム処理 19 / 23
  12. まとめ TypeScript の型で Base64 エンコードを実装した 型のパターンマッチ・再帰で実現できる 理解すべき限界 再帰深度の上限 リテラル型の制約 型ロジック自体は無検査

    実際問題は別であることも理解しよう 型システムは何のためにある? その上で探求も積極的にやっていきましょう 引き出しを持つことは大事 楽しい デコードも同じ調子で書けるので気になる方はどうぞ 23 / 23