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 type signature is (format: string, ...args: any[]). You can pass the wrong number of arguments, mix up %d and %s, or forget an argument entirely, and TypeScript won’t say a word about it.
We’re going to fix that. We’ll write a type-level parser that extracts specifiers from a string literal and produces a typed argument tuple. format('%s has %d items', 'cart', 5) will compile. format('%s has %d items', 5) won’t.
Parsing a single specifier
Let’s start simple. Given a format string containing %s, we want to produce a tuple [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 the %s, infer Rest captures everything after it, and we recurse on Rest to find more specifiers. When there’s no %s left, we return an empty tuple.
This works, but it only handles %s. util.format also supports %d for numbers, %i for integers, %f for floats, %j for JSON, and %o/%O for objects.
Multiple specifier types
Different specifiers expect different argument types. Instead of a chain of conditional types, 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 wants a string, %d and %i want a number, %f wants a number, and %o/%O/%j (object and JSON formatting) can take anything, so we use unknown. To get the type for a specifier, we just index into the map: SpecifierTypeMap['d'] gives us number. And keyof SpecifierTypeMap gives us the full union of valid specifier characters, so we don’t have to maintain it separately.
Now we need ParseFormatString to match any specifier character, not just s. The trick is matching % followed by a single character from our known set:
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 matches a % followed by one character (Spec) and the remainder (Rest). If Spec is a key of SpecifierTypeMap, we add its corresponding type to the tuple and keep going. If it’s not a recognized specifier, we skip it and continue parsing Rest. This also handles the %% escape correctly. %% gets matched as % + %, and since % isn’t a key of SpecifierTypeMap, it’s skipped and the remaining string is parsed without consuming an argument.
Tail recursion
There’s a problem with the current version. ParseFormatString builds its result like this:
[SpecifierTypeMap[Spec], ...ParseFormatString<Rest>]The recursive call is wrapped inside a spread into a new tuple. TypeScript has to hold the outer tuple “open” while it resolves the inner recursive call, which means the compiler is stacking up deferred work at each level. TypeScript caps this at around 50 levels of depth before throwing a “Type instantiation is excessively deep” error.
Since TypeScript 4.5, the compiler can eliminate tail-recursive conditional types. If the recursive call is in the tail position (returned directly as a branch, not wrapped in another type), it reuses the same stack frame. The depth limit jumps to around 1000.
To make ParseFormatString tail-recursive, we add an accumulator parameter that collects results as we go instead of building the tuple on the way back up:
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>;TryTwo changes here. First, the accumulator: instead of [SpecifierTypeMap[Spec], ...ParseFormatString<Rest>], we pass the growing tuple forward as ParseFormatString<Rest, [...Acc, SpecifierTypeMap[Spec]]>. The recursive call is now the direct return value of each branch, which is the tail position. When there’s nothing left to parse, we return Acc instead of [].
Second, the string extends S check at the top. Template literal parsing only works on string literals. If someone passes a plain string variable, S is just string and the infer patterns can’t extract anything useful. We detect this with string extends S: when S is a literal like '%s', string extends '%s' is false (not every string is '%s'). When S is the wide string type, string extends string is true. In that case, we bail out with any[] so the call isn’t rejected.
For format strings with a handful of specifiers, the tail recursion doesn’t matter. But it costs us nothing and gives us headroom if someone decides to parse a format string with 60 specifiers in it.
The format() signature
Now we can write a type-safe format:
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 ('%.2f%%', 99.9 );Expected 1 arguments, but got 2.2554Expected 1 arguments, but got 2.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 exact string literal passed as the format string. ParseFormatString<F> produces the argument tuple, and TypeScript enforces that ...args matches it exactly. Too few arguments, too many, or wrong types are all caught at compile time.
Bonus: type-level string interpolation
Everything so far validates the arguments to format. The return type is always string. But what if we went further and made the return type reflect the actual interpolated result?
To be clear: there’s not much practical value in this. At runtime, format('%s has %d items', 'cart', 5) returns 'cart has 5 items', and knowing that exact string at compile time rarely helps you. But it’s a fun exercise, and it shows just how far template literal types can go.
We walk through the format string left to right, and when we hit a specifier, replace it with the corresponding argument’s type via template literal interpolation. TypeScript can interpolate string, number, bigint, boolean, null, and undefined into template literal types natively.
// 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 trick as ParseFormatString. The Result parameter builds up the output string as we go. Each recursive call passes the accumulated result forward instead of wrapping the recursion in a template literal, keeping us in the tail position.
When Arg is a literal type like 'cart' or 5, TypeScript interpolates it directly into the template, giving us 'cart has 5 items' instead of just string. When the argument is a wide type like string or number (not a literal), the result degrades to string, which is the correct fallback.
For %o and %j, the argument type is unknown, which TypeScript can’t interpolate into a template literal. We handle that by falling back to ${string} in that position.
Now we can wire this into 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 );TryWhen you pass string literals, the return type is the fully interpolated string. Wider types fall back to string. The argument validation from ParseFormatString still works as before. We just get a more precise return type on top of it.
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 technique here (parsing string literals at the type level) is the same idea behind typed route parameters in frameworks like tRPC and Hono, SQL query typing in libraries like Kysely, and any other case where a string DSL carries structural information that the type system can exploit. Format strings are just a particularly clean example because the grammar is simple and well-defined.