Typing util.format() with TypeScript template literal types
by Brian Simon ()
Node’s util.format takes a format string with specifiers like %s, %d, and %j, then substitutes them with the provided arguments:
import { format } from 'node:util';
format('%s has %d items', 'cart', 5); // 'cart has 5 items'
format('%j', { a: 1 }); // '{"a":1}'Its (format: string, ...args: any[]) signature can’t detect missing, extra, or incorrectly typed arguments.
We’ll write a type-level parser that converts the specifiers in a string literal into an argument tuple. format('%s has %d items', 'cart', 5) will compile; format('%s has %d items', 5) won’t.
Parsing a single specifier
We’ll start with one specifier. Given a format string containing %s, we want to produce [string].
Template literal types let us pattern-match on string literals using infer. We can look for the %s pattern and extract what comes after it:
type ParseFormatString <S extends string> =
S extends `${string}%s${infer Rest }`
? [string, ...ParseFormatString <Rest >]
: [];
type Test1 = ParseFormatString <'hello %s'>;
type Test2 = ParseFormatString <'%s and %s'>;
type Test3 = ParseFormatString <'no specifiers'>;TryThe ${string} at the front matches any prefix before %s, and infer Rest captures everything after it. We then recurse on Rest to find more specifiers. When there’s no %s left, we return an empty tuple.
That works for %s, but util.format also supports %d for numbers, %i for integers, %f for floats, %j for JSON, and %o and %O for objects.
Multiple specifier types
Different specifiers expect different argument types. Instead of adding another conditional branch for each one, we can use an interface as a lookup table:
interface SpecifierTypeMap {
s : string;
d : number;
i : number;
f : number;
o : unknown;
O : unknown;
j : unknown;
}Try%s maps to string. %d, %i, and %f map to number. The object and JSON specifiers map to unknown. An indexed access like SpecifierTypeMap['d'] gives the type for one specifier. keyof SpecifierTypeMap gives the union of valid specifier characters.
With that map in place, ParseFormatString can match % followed by any character:
type ParseFormatString <S extends string> =
S extends `${string}%${infer Spec }${infer Rest }`
? Spec extends keyof SpecifierTypeMap
? [SpecifierTypeMap [Spec ], ...ParseFormatString <Rest >]
: ParseFormatString <Rest >
: [];
type Test1 = ParseFormatString <'%s has %d items'>;
type Test2 = ParseFormatString <'%f percent of %s'>;
type Test3 = ParseFormatString <'object: %j'>;TryThe %${infer Spec}${infer Rest} pattern captures the character after % as Spec and the remaining string as Rest. If Spec is in SpecifierTypeMap, we add its mapped type to the tuple. Otherwise, we skip it and continue parsing. Skipping unrecognized specifiers also handles %% without consuming an argument.
Tail recursion
This works, but there is a recursion problem. ParseFormatString builds its result like this:
[SpecifierTypeMap[Spec], ...ParseFormatString<Rest>]The recursive call is wrapped inside a tuple spread, so TypeScript can’t resolve the outer tuple until the inner call finishes. The compiler accumulates deferred work at every level and reports "Type instantiation is excessively deep" at around 50 levels.
Since TypeScript 4.5, the compiler can eliminate tail-recursive conditional types. If the recursive call is the direct result of a branch, TypeScript evaluates it in a loop instead of growing the evaluation stack. That increases the limit to around 1000 iterations.
We can make ParseFormatString tail-recursive by adding an accumulator that collects results as we parse:
type ParseFormatString <S extends string, Acc extends any[] = []> =
string extends S
? any[]
: S extends `${string}%${infer Spec }${infer Rest }`
? ParseFormatString <Rest , Spec extends keyof SpecifierTypeMap ? [...Acc , SpecifierTypeMap [Spec ]] : Acc >
: Acc ;
type Test1 = ParseFormatString <'%s has %d items'>;
type Test2 = ParseFormatString <'object: %j'>;
// Wide `string` type: can't parse, so allow anything
type Test3 = ParseFormatString <string>;TryThere are two changes here. First, instead of wrapping the recursive call in [SpecifierTypeMap[Spec], ...ParseFormatString<Rest>], we pass the growing tuple forward as ParseFormatString<Rest, [...Acc, SpecifierTypeMap[Spec]]>. The recursive call is now in tail position. When there’s nothing left to parse, we return Acc instead of [].
Second, we add the string extends S check. Template literal parsing works on string literals, but a caller can also pass a variable whose type is the wider string type. In that case, the infer patterns can’t extract useful specifiers.
The condition distinguishes the two cases. For a literal such as '%s', string extends '%s' is false because not every string is '%s'. For the wider string type, string extends string is true. We return any[] in that branch so the call remains valid.
Most format strings won’t approach the recursion limit, but the accumulator costs us little and lets the parser handle much longer strings.
The format() signature
With the parser complete, we can define a type-safe format signature:
type Format = <F extends string>(
format : F ,
...args : ParseFormatString <F >
) => string;
declare const format : Format ;
// Valid calls
const a = format ('%s has %d items', 'cart', 5);
const b = format ('%f%%', 99.9);
const c = format ('hello %s, you are %d years old', 'Alex', 30);
const d = format ('%j', { key : 'value' });
// Invalid calls
const e = format ('%s has %d items', 5);Expected 3 arguments, but got 2.2554Expected 3 arguments, but got 2.
const f = format ('%s has %d items', 'cart', 5, 'extra' );Expected 3 arguments, but got 4.2554Expected 3 arguments, but got 4.TryThe generic parameter F captures the format string literal. ParseFormatString<F> turns its specifiers into the tuple that ...args must match. TypeScript can now report missing, extra, and incorrectly typed arguments.
Bonus: type-level string interpolation
So far, we’ve validated the arguments to format, but the return type is still string. We can take the same idea further and make the return type reflect the interpolated result.
Knowing the exact string at compile time has little practical use, but the technique extends naturally from the parser.
We’ll walk through the format string from left to right. When we find a specifier, we’ll replace it with the corresponding argument type using template literal interpolation. TypeScript can interpolate string, number, bigint, boolean, null, and undefined into template literal types.
// Types that TypeScript can interpolate into template literals
type Interpolatable = string | number | bigint | boolean | null | undefined;
type FormatResult <
S extends string,
Args extends any[],
Result extends string = '',
> =
// find the first %
S extends `${infer Before }%${infer After }`
// is it %%? (escaped percent)
? After extends `%${infer AfterEscape }`
? FormatResult <AfterEscape , Args , `${Result }${Before }%`>
// otherwise, check if the next char is a specifier
: After extends `${infer Spec }${infer AfterSpec }`
? Spec extends keyof SpecifierTypeMap
? Args extends [infer Arg , ...infer RestArgs ]
? Arg extends Interpolatable
? FormatResult <AfterSpec , RestArgs , `${Result }${Before }${Arg }`>
// non-interpolatable types (objects via %o/%j) fall back to string
: FormatResult <AfterSpec , RestArgs , `${Result }${Before }${string}`>
: `${Result }${S }` // not enough args, return what we have
// unknown specifier, leave it and keep going
: FormatResult <AfterSpec , Args , `${Result }${Before }%${Spec }`>
: `${Result }${S }`
: `${Result }${S }`;
type Test1 = FormatResult <'%s has %d items', ['cart', 5]>;
type Test2 = FormatResult <'100%% complete', []>;
type Test3 = FormatResult <'%s is %s', ['TypeScript', 'fun']>;TryFormatResult uses the same accumulator pattern as ParseFormatString. The Result parameter builds the output string as we go, and each recursive call passes that result forward to stay in tail position.
If Arg is a literal type such as 'cart' or 5, TypeScript interpolates it directly into the template and produces 'cart has 5 items'. If the argument has a wider type such as string or number, the result falls back to string.
The %o and %j specifiers map to unknown, which TypeScript can’t interpolate into a template literal. For those positions, we’ll fall back to ${string}.
Now we can add FormatResult to our Format type:
type Format = <
F extends string,
const Args extends ParseFormatString <F >,
>(
format : F ,
...args : Args
) => FormatResult <F , Args >;
declare const format : Format ;
const a = format ('%s has %d items', 'cart', 5);
const b = format ('hello %s', 'world');
// Wide types degrade to `string`
const who : string = 'someone';
const c = format ('hello %s', who );TryString literals now produce a fully interpolated return type, while wider types fall back to string. ParseFormatString still validates the arguments as before.
Full code
interface SpecifierTypeMap {
s : string;
d : number;
i : number;
f : number;
o : unknown;
O : unknown;
j : unknown;
}
type ParseFormatString <S extends string, Acc extends any[] = []> =
string extends S
? any[]
: S extends `${string}%${infer Spec }${infer Rest }`
? ParseFormatString <Rest , Spec extends keyof SpecifierTypeMap ? [...Acc , SpecifierTypeMap [Spec ]] : Acc >
: Acc ;
type Interpolatable = string | number | bigint | boolean | null | undefined;
type FormatResult <
S extends string,
Args extends any[],
Result extends string = '',
> =
S extends `${infer Before }%${infer After }`
? After extends `%${infer AfterEscape }`
? FormatResult <AfterEscape , Args , `${Result }${Before }%`>
: After extends `${infer Spec }${infer AfterSpec }`
? Spec extends keyof SpecifierTypeMap
? Args extends [infer Arg , ...infer RestArgs ]
? Arg extends Interpolatable
? FormatResult <AfterSpec , RestArgs , `${Result }${Before }${Arg }`>
: FormatResult <AfterSpec , RestArgs , `${Result }${Before }${string}`>
: `${Result }${S }`
: FormatResult <AfterSpec , Args , `${Result }${Before }%${Spec }`>
: `${Result }${S }`
: `${Result }${S }`;
type Format = <
F extends string,
const Args extends ParseFormatString <F >,
>(
format : F ,
...args : Args
) => FormatResult <F , Args >;
// ----
declare const format : Format ;
const a = format ('%s has %d items', 'cart', 5);const b = format ('hello %s', 'world');const c = format ('100%% of %s users (%d)', 'active', 42);const d = format ('%j', { key : 'value' });
// Type errors
const e = format ('%s has %d items', 5);Expected 3 arguments, but got 2.2554Expected 3 arguments, but got 2.
const f = format ('%s has %d items', 'cart', 5, 'extra' );Expected 3 arguments, but got 4.2554Expected 3 arguments, but got 4.TryThe same technique of parsing string literals at the type level also powers typed route parameters in frameworks such as tRPC and Hono, and SQL query typing in libraries such as Kysely. Format strings make a compact example because their grammar is small and well defined.