Search code examples
typescriptsubtyping

When does a template literal type with placeholders extend another one?


What is the subtype rule for template literal types with placeholders (like `${number}em`)? It's not covered by the Template Literal Types part of the handbook and I didn't find anything for it in searches though I could have missed it somewhere; it's tricky to search for.

I ask because I was surprised recently by `${number}` extends `${string}` being true. On reflection, I have a reasonable thought for why it is, but I'm looking either for confirmation and details if I'm right or the correct explanation if I'm wrong. (For context, this came up while I was trying to prove to myself that my answer to this question was reliable.)

My thought is:

  • Template literal type S is a subtype of template literal type T if a string that would match S would also match T.

That's true for my `${number}` extends `${string}` example because any series of characters that would match `${number}` (like "123") would also match `${string}`.

That also seems like a reasonable fit for the general rule that "...any term of type S can safely be used in any context where a term of type T is expected...".

Is that correct and if so, is there further nuance? If not, what is the correct definition?

FWIW, here is my somewhat-meandering series of tests that I used to test my intuition:

type A = `${number}` extends `${number}` ? true : false;
//   ^?
// true, since they're the same thing, so yeah, it's a match

type B = `${number}` extends `${number|string}` ? true : false;
//   ^?
// true, anything matching `${number}` would also match `${number|string}`

type C = `${number}` extends `${string}` ? true : false;
//   ^?
// true, which makes sense for a template literal even though number isn't a subtype of
// string, because anything matching `${number}` would also match `${string}` --
// that is, a series of digits is not just a valid number, but also a valid string

type D = `${string}` extends `${number}` ? true : false;
//   ^?
// false, because the converse isn't true; not all sequences of characters are
// valid numbers

type E = `${number|string}` extends `${number}` ? true : false;
//   ^?
// false - same as D, basically

type F = `${number}` extends `${string}${number}` ? true : false;
//   ^?
// false because something matching `${number}` wouldn't match `${string}${number}`

type G = `${string}${number}` extends `${string}${number}` ? true : false;
//   ^?
// true, they're the same thing

type H = `${number}${string}` extends `${string}${number}` ? true : false;
//   ^?
// false, for the same reason as D and E

type I = `${number}x` extends `${string}x` ? true : false;
//   ^?
// true - basically the same as C, just with an x on it

type J = `${number}x` extends `${string}y` ? true : false;
//   ^?
// false because something matching `${number}x` wouldn't match `${string}y`

Playground link


Solution

  • Assignability of template literal types with placeholders (with wide types like string as in `abc${string}ghi` or with indeterminate types like infer U as in T extends `abc${infer U}ghi` ? U : never) was implemented in microsoft/TypeScript#43361. So that's the authoritative answer for how this works.

    Conceptually, yes, X extends Y when every value of type X can be safely assigned to a variable of type Y. So your intuition is correct and explains most of what's going on.


    Except that template literal types with placeholders don't always behave as some people expect.

    For example, when two placeholders are next to each other, the first one matches exactly one character from the input. So "a" doesn't match `a${string}${string}`:

    type Hmm = "a" extends `a${string}${string}` ? true : false
    //   ^? type Hmm = false
    

    But template literals consisting only of repeated string placeholders are collapsed to string, so "" does match `${string}${string}`. See microsoft/TypeScript#57355.

    type Wha = "" extends `${string}${string}` ? true : false
    //   ^? type Wha = true
    

    And a number placeholder doesn't refer to "those strings which are what you get when you serialize a number with a template literal string"; instead it refers to "anything which can be successfully parsed as a number", which also leads to weird behavior. See microsoft/TypeScript#57404 and this comment on a related issue microsoft/TypeScript#41893:

    type Oops = "a       0" extends `a${number}` ? true : false
    //   ^? type Oops = true
    

    So while TypeScript does more or less follow the rule that "if X is assignable to Y then X extends Y ? true : false is true, sometimes it's not obvious when X is assignable to Y.

    Playground link to code