Diagnose and fix a type that reports "instantiation is excessively deep"
Join concatenates a tuple of strings with a separator. It works for short tuples but fails on a long one with "Type instantiation is excessively deep and possibly infinite".
Explain why the recursion is expensive here, and restructure it so a tuple of a few hundred elements still resolves. Keep the same public signature and the same result.
type Join<T extends string[], Sep extends string> =
T extends [infer H extends string, ...infer R extends string[]]
? R extends []
? H
: `${H}${Sep}${Join<R, Sep>}`
: '';
type Short = Join<['a', 'b', 'c'], '-'>; // 'a-b-c'
type Long = Join<HundredsOfStrings, '-'>; // error: excessively deep
Find and fix the bug.
The recursive call sits inside a template literal, so it is not in tail position: every level must stay on the instantiation stack waiting to splice its result, and the checker's depth budget runs out. Thread an accumulator so the branch returns the recursive call directly, concatenating before it recurses — the checker optimises that tail-recursive shape and unrolls it far deeper.
- ✗Blaming the tuple spread or the
inferconstraints instead of the call's position - ✗Not recognising a recursive call wrapped in a template literal is not a tail call
- ✗Capping the input length instead of moving the recursion into tail position
- →Why does an accumulator parameter turn this into a tail-recursive type?
- →How would you keep the two-parameter public signature while recursing on three?
The error does not mean the type is infinite. It means the checker exhausted its instantiation-depth budget. The key is where the recursive call sits.
// NOT tail position: the call is embedded inside a template literal
type Join<T extends string[], Sep extends string> =
T extends [infer H extends string, ...infer R extends string[]]
? R extends [] ? H : `${H}${Sep}${Join<R, Sep>}`
: '';
Every level has to wait for the nested Join to produce a result before it can splice it into the string, so all frames are live at once. An accumulator moves the concatenation before the call, making the call the last thing the branch does — a shape the checker recognises and unrolls iteratively.
type Join<T extends string[], Sep extends string, Acc extends string = ''> =
T extends [infer H extends string, ...infer R extends string[]]
? Join<R, Sep, Acc extends '' ? H : `${Acc}${Sep}${H}`>
: Acc;
type Short = Join<['a', 'b', 'c'], '-'>; // 'a-b-c' — the signature is unchanged
The public signature is preserved: the third parameter has a default, so callers never see it.