Yuzhe's Blog

yuzhes

Percentage Parser

Percentage Parser

Challenge Link

Challenge

Implement PercentageParser<T extends string>.
According to the regex /^(\+|\-)?(\d*)?(\%)?$/, match T and extract three capture groups.

The structure should be: [sign, number, unit]
If a group is not captured, default it to an empty string ''.

type PString1 = ""
type PString2 = "+85%"
type PString3 = "-85%"
type PString4 = "85%"
type PString5 = "85"

type R1 = PercentageParser<PString1> // ['', '', '']
type R2 = PercentageParser<PString2> // ['+', '85', '%']
type R3 = PercentageParser<PString3> // ['-', '85', '%']
type R4 = PercentageParser<PString4> // ['', '85', '%']
type R5 = PercentageParser<PString5> // ['', '85', '']

Solution

type ParseSign<T extends string> =
  T extends `${'+' | '-'}${string}` ? T extends `${infer S}${string}` ? S : '' : ''

type ParseUnit<T extends string> =
  T extends `${string}%` ? '%' : ''

type ParseNumber<T extends string, S extends string, U extends string> =
  T extends `${S}${infer N}${U}` ? N : ''

type PercentageParser<T extends string> =
  T extends `${infer A}${infer _Rest}`
    ? [
        ParseSign<T>,
        ParseNumber<T, ParseSign<T>, ParseUnit<T>>,
        ParseUnit<T>
      ]
    : ['', '', '']

Actually, the cleanest solution leverages a single conditional type chain:

type PercentageParser<T extends string> =
  T extends `${'+' | '-'}${infer Rest}`
    ? T extends `${infer Sign}${Rest}`
      ? Rest extends `${infer Num}%`
        ? [Sign, Num, '%']
        : [Sign, Rest, '']
      : ['', '', '']
    : T extends `${infer Num}%`
      ? ['', Num, '%']
      : ['', T, '']

Breaking it down

Step 1 — Detect optional sign

T extends `${'+' | '-'}${infer Rest}`

If T starts with + or -, we match the sign and capture the Rest of the string.
The union '+' | '-' acts as an OR — TypeScript distributes over it.

Step 2 — Extract the sign character

T extends `${infer Sign}${Rest}`

We already know T starts with a sign, so this extracts Sign as a single-char prefix. Since Rest is known, TypeScript pins Sign to exactly the first character.

Step 3 — Detect optional % suffix in the rest

Rest extends `${infer Num}%`

If the remaining string ends with %, extract the number part Num and use '%' as unit. Otherwise the unit is ''.

Step 4 — Handle no-sign case

T extends `${infer Num}%`
  ? ['', Num, '%']
  : ['', T, '']

When there’s no sign, check if T ends with %. If yes, return ['', Num, '%']; otherwise the whole string is the number with no unit.

Example trace

PercentageParser<'+85%'>
→ matches '+' | '-' prefix → Rest = '85%'
→ Sign = '+'
'85%' extends `${infer Num}%` → Num = '85'
→ ['+', '85', '%'] ✓

PercentageParser<'85%'>
→ does NOT match '+' | '-' prefix
'85%' extends `${infer Num}%` → Num = '85'
→ ['', '85', '%'] ✓

PercentageParser<''>
→ does NOT match '+' | '-' prefix
'' does NOT match `${infer Num}%`
→ ['', '', ''] ✓

Deep Dive

Why T extends \{'+' | '-'}\{infer Rest}“ works as an OR

TypeScript distributes union types in conditional type position. Writing '+' | '-' inside a template literal is syntactic sugar for:

“Does T match the pattern starting with +, OR the pattern starting with -?”

Both branches resolve to the same Rest type (the tail of the string), so the union collapses cleanly.

The two-step sign extraction trick

You might wonder: if we matched T extends \{'+' | '-'}\{infer Rest}“, why not directly have the sign available?

The catch is that the literal '+' | '-' is a union, not a captured infer. We can’t name it directly. The workaround is to then ask:

T extends `${infer Sign}${Rest}`

Here Rest is now a known concrete type (inferred in the outer extends), so TypeScript can pin Sign to exactly the one remaining prefix character. This is a common pattern in type-level string parsing.

Comparison with a helper-type approach

Some community solutions split the logic into three small helper types:

type GetSign<T extends string> =
  T extends `${'+' | '-'}${string}` ? (T extends `${infer S}${string}` ? S : never) : ''

type GetUnit<T extends string> =
  T extends `${string}%` ? '%' : ''

type GetNumber<T extends string,
               S extends string = GetSign<T>,
               U extends string = GetUnit<T>> =
  T extends `${S}${infer N}${U}` ? N : ''

type PercentageParser<T extends string> =
  [GetSign<T>, GetNumber<T>, GetUnit<T>]

This is arguably more readable (each helper has a single responsibility) and closely mirrors how you’d decompose the regex /^(\+|\-)?(\d*)?(\%)?$/ into three independent capture groups.

The trade-off: the nested single-chain version avoids re-evaluating T three times, which can matter for type-checking performance at scale.

Edge cases to watch

InputExpectedNotes
""['', '', '']Empty string — all groups default to ''
"+"['+', '', '']Sign only, no digits, no unit
"%"['', '', '%']Unit only
"+%"['+', '', '%']Sign + unit, no digits
"-0%"['-', '0', '%']Negative zero percent

Relation to other string-parsing challenges

ChallengeTechnique
Trim (108)Remove prefix/suffix characters
Replace (116)Split on a substring
StartsWith (2688)Check prefix with template literal
EndsWith (2693)Check suffix with template literal
PercentageParser (1978)Extract prefix + middle + suffix with conditional chain
Split (2822-hard)Recursive split on delimiter

PercentageParser is a clean exercise in combining prefix detection (StartsWith-style) with suffix detection (EndsWith-style) in a single type expression.

Key Takeaways