Home > Mobile >  string literal parsing requires a space to work
string literal parsing requires a space to work

Time:01-04

Let's say I have these string literal types.

type VarType = 'vec2' | 'vec3' | 'vec4'
type Src = 'vec2 abc;'

The goal here is to parse the type vec2 and the name abc at the type level. And keep in mind that whitespace could expansive (multiple spaces, new lines and/or tabs).

So this would also need to parsed similarly:

type Src2 = '  vec3\n\t def\n ;'

So to start, I want to look for the VarType and then send the rest of the string literal on for further processing (trimming whitespace, looking for the next things, etc.)

But we already start to have problems:

type TestA = Src extends `${infer U extends VarType}${string}` ? U : false
// expected: 'vec2'
// actual:   'vec2' | 'vec3' | 'vec4'

Here, I'd expect U to be inferred specifically as what's in the string, and ${string} should match the rest of the string type that beings with a space. But it fails to infer and returns the constraint instead.

But if a space is added between, it works as expected:

type TestB = Src extends `${infer U extends VarType} ${string}` ? U : false
// expected: 'vec2'
// actual:   'vec2'

Why?

Shouldn't typescript be able to figure out that ${string} starts with a space here?


Interestingly, if I change the ${string} to an ${infer Tail}, then it fails to match at all.

type TestC = Src extends `${infer U extends VarType}${infer Tail}`
  ? [U, Tail] : false
// expected: ['vec2', ' abc;']
// actual:   false

I don't understand why making part of a conditional type infer should change the success to a failure. The matching seems like it should be independent of the ability to infer types from that match.

And again, adding the space fixes it:

type TestD = Src extends `${infer U extends VarType} ${infer Tail}`
  ? [U, Tail] : false
// expected: ['vec2', 'abc;']
// actual:   ['vec2', 'abc;']

See Playground

CodePudding user response:

For template literal types a placeholder is any type position that requires inference to match. This happens for infer such as in `${infer X}` or `${infer Y extends Z}`. It also happens for "pattern" template literals such as in `${string}` or `${number}`, as implemented in microsoft/TypeScript#40598.

One feature, and limitation, of TypeScript is when a placeholder is followed immediately by another placeholder, as in `${string}${string}` or `ab${infer Y extends Z}${number}cd`, that first placeholder is going to match a single character, no matter what.

It's a feature because it lets you parse a string character-by-character recursively via a constructs like T extends `${infer F}${infer R}` ? ... : ... where F is the first character of T and R is the rest of T. (If F could match less than one or more than one character, then this sort of parsing would be impossible, or at least much more complicated).

And it's a limitation because of the sort of behavior you're running into here; there's no good way to get the first placeholder to match some variable-length string. See microsoft/TypeScript#47048 for more information.


If you add a space between two placeholders, they are no longer immediately adjacent, and this behavior stops happening. This explains why you get different behavior with and without a space, although it doesn't actually explain the particular thing you do see. After all, TestA doesn't evaluate to a single character, it evaluates to VarType. Why?

I suspect that first U is inferred as a single character, which fails to match the extends VarType constraint. So inference fails. Then, when inference fails, type variables fall back to their constraints. So U falls back to VarType. Now the compiler tries to match Src against `${VarType}${string}`, which succeeds. And you get the weird behavior you're seeing here. The inference neither completely succeeds (where TestA is "vec2") nor completely fails (where TestA is false); instead you get this in-between thing. This is very similar to the issue at microsoft/TypeScript#49839, which is marked as a bug... although it's also marked as "Cursed?" which means something like "this isn't good but the alternatives might be even worse", so I wouldn't expect to see a change anytime soon.


Sometimes when I have situations like this I can split the single two-placeholder check into two one-placeholder checks:

type TestA = Src extends `${VarType}${infer R}` ?
    Src extends `${infer U}${R}` ? U : never : false
// type TestA = "vec2"

This may or may not work for your use case.

Playground link to code

  • Related