62
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptの型で遊ぶ時、再帰制限を(合法的に)突破する

Last updated at Posted at 2020-09-14

2024/9/11追記:現在のバージョン(5.6) において、再帰深度上限は1000になっています。多くのケースはこの記事のような手法を使うまでもなく単純な再帰で解決できるはずです

TypeScriptの型の再帰は50が上限です。

先日、 @kgtkr さんがTypeScriptの型で遊ぶ時、再帰制限を無効化するという記事で、 その再帰制限を、ソースコードを書き換えることで突破して、型レベル BrainF**k などで遊んでいるのを見ました。

タイトルで合法的と言っておきながら、別にソースコードを書き換えるのが何らかの法に触れる訳ではないのですが、とは言え @kgtkr さんも記事の中で、

  • 当然ですがプロダクトで使うことは想定していません、やめましょう。

と言っている通り、これをプロダクトに採用するわけにはいきません。

というわけで、ソースコード書き換えをせずに再帰制限を突破してやろう、というのがこの記事の趣旨です。

文字列を分割する(再帰制限に引っかかる例)

Template literal types がマージされたので、 typescript@next を使えば文字列リテラル型を分割できます。単純に考えると、こんなコードが思い浮かびますね。

type ToTuple<T extends string> = T extends `${infer U}${infer V}` ? [U, ...ToTuple<V>] : [];
type A = ToTuple<'Nyan'>; // ['N', 'y', 'a', 'n']

-> Playgroundで見る

このまま文字列を長くしていくと、23文字までは大丈夫ですが、

type ToTuple<T extends string> = T extends `${infer U}${infer V}` ? [U, ...ToTuple<V>] : [];
type A = ToTuple<'Nyanyanyanyanyanyanya!!'>;
type B = A['length']; // 23

-> Playgroundで見る

次で死にます。つらい。

type ToTuple<T extends string> = T extends `${infer U}${infer V}` ? [U, ...ToTuple<V>] : [];
type A = ToTuple<'Nyanyanyanyanyanyanya!!!'>; // Type instantiation is excessively deep and possibly infinite.(2589)
type B = A['length'];

-> Playgroundで見る

再帰制限が50なのに23で頭打ちになっているということは1回の ToTuple の再帰の間に内部の再帰カウンタが2回インクリメントされているということになります。コードを追っていないので詳しく分かりませんが、 ...ToTuple<V> で呼び出した結果を展開しているあたりが問題なのかもしれません。
これは比較的簡単に回避できて、少し書き方を変えるだけで、

type ToTuple<T extends string, Result extends string[] = []> = T extends `${infer U}${infer V}` ? ToTuple<V, [...Result, U]> : Result;
type A = ToTuple<'Nyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!'>;
type B = A['length']; // 45

-> Playgroundで見る

とまあこのくらいまで伸ばすことが可能です。
これで上限の50に近付きましたね。しかしこれ以上はこのやり方では無理で、46文字以上にするとエラーになります。

どうすればいいんだ……やはりソースコードをいじるしかないのか……。

と、なる前に、ちょっと考え方を変えてみましょう。

再帰の再帰

再帰制限というのは、つまりスタックの高さに対する制限です。
ToTuple が 46 文字分で引っかかったのは、

---------------
ToTuple<T45>  |
ToTuple<T44>  |
...          50個
ToTuple<T4>   |
ToTuple<T3>   |
ToTuple<T2>   |
ToTuple<T1>   |
...           |
---------------

とまあこんな感じで50個分のスタックがいっぱいになったからです。1文字読む度に1個スタックが高くなるのですから、このままの実装では上限を突破できません。
しかし、再帰制限というのはあくまでスタックの高さに対する制限です。ステップ数の制限ではありません。
天井が低いのでこれ以上皿が積めないなら、横にもう一個山を作ってしまえば良いのです。

----------------------------------
ToTuple<T45>  |   ToTuple<T90>   |  
ToTuple<T44>  |   ToTuple<T89>   |  
...          50個 ...          50個
ToTuple<T4>   |   ToTuple<T49>   |  
ToTuple<T3>   |   ToTuple<T48>   |  
ToTuple<T2>   |   ToTuple<T47>   |  
ToTuple<T1>   |   ToTuple<T46>   |
...           |   ...            |
----------------------------------

横のもう一個の山をどうやって増やすかというと、TypeScriptの型レベル計算にはループ構文はないので、やはり再帰でやらなければなりません。なので上図のように真横に山を増やすことはできない(再帰する時にどうしてもスタックを消費するため)のですが、例えば、
「最大20文字読み取る」という操作Aと、「Aを20回繰り返す」という操作Bを考えてみると、20*20で400文字読み取れることになります。それでいて、スタックの高さは、20回再帰したときのBの高さ+20回再帰したときのAの高さが一番高くなるはずなので、それでも40回で、上限に達しないはずです(フラグ)。

なのでとりあえず、 ToTuple を上限20文字まで読んでTupleにするように改造してみましょう。この後同じようなジェネリクスを何個か作ることになるので、このジェネリクスは ToTuple0 とします。

type First<T extends string> = T extends `${infer U}${infer _}` ? U : never;
type RemoveFirst<T extends string> = T extends `${infer _}${infer U}` ? U : never;

type ToTuple0<T extends string, Result extends string[] = [], Counter extends unknown[] = []> =
    ['', Counter['length']] extends { 0: T } | { 1: 20 } ? [T, Result] :
    ToTuple0<RemoveFirst<T>, [...Result, First<T>], [...Counter, unknown]>;

type A = ToTuple0<'Nyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!'>;
type B = A[1]['length']; // 20

-> Playgroundで見る

一気に複雑になって、謎の判定式が含まれるようになりました。 ['', Counter['length']] extends { 0: T } | { 1: 20 } } ? というのが何かと言うと、 '' extends T もしくは Counter['length'] extends 20 のどちらかを満たしているということを表す条件式です。
どうやらTypeScriptでは再帰の途中でConditional Typesが含まれると、そこで再帰カウンタが増えてしまうようなのですが、 T1 extends U1T2 extends U2 のどちらかを満たすという条件を [T1, T2] extends { 0: U1 } | { 1: U2 } という条件に置き換えることで、再帰カウンタの上昇を1回に抑えることができます。
ここでは利用しませんが、 同様に、T1 extends U1T2 extends U2 の両方を満たすという条件は、 [T1, T2] extends [U1, U2] と表記することも可能です。

つまり、T が空文字か、 Counter の長さが 20 だった時に [T, Result] を返す、そうでなければ ToTuple0 を再帰的に呼び出す、という処理を行っている訳ですね。
こうすることによって、どんなに長い文字列リテラル型を渡しても再帰制限に引っかからない代わりに、20文字までしか分割しないジェネリクスが作れました。次は、このジェネリクスを再帰的に呼び出すジェネリクスをつくります。

type ToTuple1<T extends string, Result extends string[] = [], Counter extends unknown[] = []> =
    ToTuple0<T, Result> extends [infer T, infer Result] ?
    ['', Counter['length']]  extends { 0: T } | { 1: 19 } ? Result :
    ToTuple1<Extract<T, string>, Extract<Result, string[]>, [...Counter, unknown]> :
    never;

type A = ToTuple1<'Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!!'>;
type B = A['length']; // 100

-> Playgroundで見る

100文字だって読み込める。
やっていることは何かというと、まず ToTuple0 を呼び出して、その結果を型推論を利用して受け取ります。
その後は同じような条件分岐を行って、 Counter が上限に達していなかったら再帰する処理になっています。
先程と違って上限が19になっているのは、判定の前に ToTuple0 の呼び出しが一回挟まるからで、上限に達したと判定された時には ToTuple0 は20回呼び出されたことになります。

これで理論的には400文字まで読めるはずです。やってみましょう。

type A = ToTuple1<`
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
`>; // Type instantiation is excessively deep and possibly infinite.(2589)
type B = A['length'];

-> Playgroundで見る

駄目でした(フラグ回収)

敗因は、Conditional Types を2重に使わないといけないため、再帰カウンタが2回インクリメントされてしまうことです。つまり、 ToTuple0 の再帰一回のコストが1なのに対して、 ToTuple1 の再帰1回のコストは2とカウントする必要があるようです。色々試してみましたが、コストの合計が 46 以下になればエラーが出ないので、その範囲で最も読める文字数が大きい組み合わせは、 ToTuple0 を22回再帰して ToTuple1 を12回再帰するか、 ToTuple0 を24回再帰して ToTuple1 を11回再帰するのどちらかでした。つまり最大264文字まで読み取れることになります。

type First<T extends string> = T extends `${infer U}${infer _}` ? U : never;
type RemoveFirst<T extends string> = T extends `${infer _}${infer U}` ? U : never;

type ToTuple0<T extends string, Result extends string[] = [], Counter extends unknown[] = []> =
    ['', Counter['length']] extends { 0: T } | { 1: 22 } ? [T, Result] :
    ToTuple0<RemoveFirst<T>, [...Result, First<T>], [...Counter, unknown]>;

type ToTuple1<T extends string, Result extends string[] = [], Counter extends unknown[] = []> =
    ToTuple0<T, Result> extends [infer T, infer Result] ?
    ['', Counter['length']]  extends { 0: T } | { 1: 11 } ? Result :
    ToTuple1<Extract<T, string>, Extract<Result, string[]>, [...Counter, unknown]> :
    never;

type A = ToTuple1<`
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
Nyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanyanya!!!
`>;
type B = A['length']; // 264

-> Playgroundで見る

再帰の再帰の再帰

さて。

これでFinish!?な訳無いデショ!

264文字、当初の45文字に比べれば6倍近く読めるようになりましたが、ここで終わっては所詮その程度。英数字なら1ツイートに収まってしまいます。せめて1000文字くらいは行きたい。
どうするか。同じことをします。
つまり、 ToTuple0 を繰り返し呼ぶ ToTuple1 を作ったように、 ToTuple1 を繰り返し呼ぶ ToTuple2 を作るのです。

作りました。なお ToTuple1Result ではなく ToTuple0 と同じように [T, Result] を返す仕様に改造してあります。

type ToTuple2<T extends string, Result extends string[] = [], Counter extends unknown[] = []> =
    ToTuple1<T, Result> extends [infer T, infer Result] ?
    ['', Counter['length']]  extends { 0: T } | { 1: 7 } ? Result :
    ToTuple2<Extract<T, string>, Extract<Result, string[]>, [...Counter, unknown]> :
    never;

-> Playgroundで見る

コスト上限は47で、 ToTuple0 を15回、 ToTuple1 を8回、 ToTuple2 を8回繰り返すパターンが最も効率が良く、960文字まで読み取れます。憧れの1000文字まであと少し。

再帰の再帰の再帰の再帰の……

同じようにして ToTuple3, ToTuple4, と追加していきます。それぞれの最適な繰り返し回数の組み合わせは以下のようになります

繰り返し回数 読み取り可能文字数
ToTuple0 45 45
ToTuple0, ToTuple1 22, 12 264
ToTuple0, ToTuple1, ToTuple2 15, 8, 8 960
ToTuple0, ToTuple1, ToTuple2, ToTuple3 12, 6, 6, 6 2592
ToTuple0, ToTuple1, ToTuple2, ToTuple3, ToTuple4 9, 5, 5, 5, 5 5625
ToTuple0, ToTuple1, ToTuple2, ToTuple3, ToTuple4, ToTuple5 10, 4, 4, 4, 4, 4 10240
ToTuple0, ToTuple1, ToTuple2, ToTuple3, ToTuple4, ToTuple5, ToTuple6 3, 4, 4, 4, 4, 4, 4 12288
ToTuple0, ToTuple1, ToTuple2, ToTuple3, ToTuple4, ToTuple5, ToTuple6, ToTuple7 10, 3, 3, 3, 3, 3, 3, 3 21870
ToTuple0, ToTuple1, ToTuple2, ToTuple3, ToTuple4, ToTuple5, ToTuple6, ToTuple7, ToTuple8 4, 3, 3, 3, 3, 3, 3, 3, 3 26244

DB設計でこんなの書いたら怒られそうな表になってしまった。なお5625から先は理論値です。試してみたらブラウザのV8エンジンが落ちました。4701までは大丈夫だったのでローカルで頑張れば行けるかもしれない。
再帰制限は回避できても、効率的にはかなり良くない計算をやっているので(内部的には再帰1回ごとにサイズを+1した配列が作られる)メモリもCPUもバカ食いしますが、1000文字くらいならなんとかなりそうです。1000文字あればけっこう型遊びができますね、そう、こんな風に。

image.png

62
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
62
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?