#14427•Hard

Printf 2

Implement printf in the type system: swap %d, %s and %b for the matching argument, unescape %%, and collapse to never when the arguments do not line up.

C formats its strings at runtime. This one is formatted while you type.

Printf<Fs, Args> takes a format string and a tuple of arguments and returns the formatted result as a string literal type. %d, %s and %b each consume one argument, and that argument has to be a number, a string or a boolean respectively. %% collapses to a single %. When the wildcards and the arguments fail to line up, in count or in type, the answer is never.

[object Object]

The substitution is the easy half. The work is in deciding, one character at a time, whether a % opens a wildcard or an escape, and in making sure a bad argument poisons the whole result instead of quietly producing the wrong string.

Challenge Instructions: Printf 2

Hard

printf is a c function which prints a format string with some arguments to stdout.

For this challenge, your task is to implement the Printf type which takes a format string as the first generic argument, and an array of arguments as the second generic argument.

Printf must follow a couple of rules.

  1. %d | %s | %b wildcards must be replaced with the appropriate type arguments. 1.1 %d replaced with number 1.2 %s replaced with string 1.3 %b replaced with boolean
  2. %% must be escaped to a single %.
  3. Number of wildcards and arguments must be the same; else never.

Example:

[object Object]

View on GitHub: https://tsch.js.org/14427

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

type Printf<Fs extends string, Args extends any[]> = any

/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'

type cases = [
  Expect<Equal<Printf<'%s %s', ['Hello', 'World']>, 'Hello World'>>,
  Expect<Equal<Printf<'%s %d %s %b', ['All', 3, 'types', true]>, 'All 3 types true'>>,
  Expect<Equal<Printf<'Invalid', [true]>, never>>,
  Expect<Equal<Printf<'Invalid %b', []>, never>>,
  Expect<Equal<Printf<'Bad Order %d %s %b', [true, 1, 's']>, never>>,
  Expect<Equal<Printf<'Valid %%%b', [true]>, 'Valid %tru

Pro Challenge

Get access to all 200+ challenges, including every medium, hard, and extreme one.

One-time payment, lifetime access. 30-day money-back guarantee.

Detailed Explanation

type Wildcard = {
  d: number
  s: string
  b: boolean
}
 
type Printf<
  Fs extends string,
  Args extends any[],
  Acc extends string = '',
> = Fs extends `${infer Head}%${infer Rest}`
  ? Rest extends `%${infer Tail}`
    ? Printf<Tail, Args, `${Acc}${Head}%`>
    : Rest extends `${infer Flag extends keyof Wildcard}${infer Tail}`
      ? Args extends [infer A extends Wildcard[Flag], ...infer Remaining]
        ? Printf<Tail, Remaining, `${Acc}${Head}${A}`>
        : never
      : never
  : Args extends []
    ? `${Acc}${Fs}`
    : never

The wildcard table

Three flags, three types, written down once. Wildcard['d'] answers "what type must this argument have", and keyof Wildcard (which is 'd' | 's' | 'b') answers "is this character a flag at all".

Finding the next percent sign

Template literal inference is lazy. In `${infer Head}%${infer Rest}` the placeholder Head takes the shortest prefix that still lets the rest of the pattern match, so it stops at the first %:

// 'Valid %%%b' extends `${infer Head}%${infer Rest}`
// Head = 'Valid '
// Rest = '%%%b'

Head is finished plain text. Everything undecided sits at the front of Rest.

The escape branch, checked first

If Rest begins with another %, the pair was an escape. One % goes to the output and the walk continues behind it:

// Rest = '%%b'  ->  Tail = '%b'
Printf<Tail, Args, `${Acc}${Head}%`>

Args is handed on untouched: an escape consumes no argument. Checking this branch before the flag branch is what keeps '%%%b' unambiguous, since the first two characters pair off as an escape and only the third % is free to open the %b wildcard.

The wildcard branch

Otherwise the first character of Rest ought to be a flag. Two infer placeholders written side by side split a string into head character and tail, since the first one can only match a single character: 'd %s' against `${infer Flag}${infer Tail}` gives Flag = 'd' and Tail = ' %s'.

Adding extends keyof Wildcard to that infer turns the split into a filter. TypeScript infers the character and then tests it against the constraint; a failed test sends the whole conditional down its false branch:

// 'x' extends `${infer F extends 'd' | 's' | 'b'}${infer R}` ? F : 'no match'
// 'no match'

So %x is not a wildcard, and something like Printf<'100% sure', []> comes out as never rather than as a half-formatted string.

Consuming one argument

With a flag in hand, the matching argument is pulled off the front of Args:

[object Object]

One line, three of the four rules. The tuple pattern demands at least one element, which rejects Printf<'Invalid %b', []>. The extends Wildcard[Flag] constraint demands that element match the flag, which kills Printf<'Bad Order %d %s %b', [true, 1, 's']> on its first step. And Remaining is what the next call receives, so arguments are consumed strictly left to right.

A is inferred as the literal argument rather than its widened type, so `${A}` writes 'Hello', 3 or true straight into the output. Booleans interpolate as 'true' and 'false', which is where the %b tests get their text.

Why nothing is substituted twice

Acc carries the output built so far, and the patterns only ever match against Fs, the part of the format string nobody has looked at yet. Text that has already been emitted, literal or substituted, is out of reach.

That is the point of Printf<'Nothing happens %s %s %s', ['%s', '%s', '%s']>. Every argument is itself the string '%s'. A version that rebuilt its answer by rescanning the accumulated output would see three fresh wildcards and ask for nine arguments. Here they land in Acc as inert characters and the result is 'Nothing happens %s %s %s'. Threading the output through a parameter also keeps the recursion tail shaped, so TypeScript walks a long format string without nesting one conditional type per character.

The base case

When no % is left in Fs, the formatting is done:

: Args extends []
  ? `${Acc}${Fs}`
  : never

Whatever remains of Fs is trailing plain text and gets appended. The Args extends [] check is the other half of rule 4: an argument no wildcard claimed makes the call never, which is how Printf<'Invalid', [true]> fails without ever reaching a wildcard branch.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges