Search code examples
typescripttypescript-types

Make a TypeScript type which represents the first letter of a string type


I have a function that outputs the first letter of the string passed into it. In my case I know what the possible values are, assume either hard-coded or through a generic, and want the function’s return type to be exactly what the letter being returned is, so I can pass this on to later functions.

I have actually found a rather inelegant way to do it, but I have a feeling that it’s not stable and may not work in future versions of TypeScript, as ${infer FirstLetter} could technically represent any number of characters… it just so happens that TypeScript currently only finds the first one:

type Speed = 'fast' | 'slow' | 'medium';
type SpeedShort = Speed extends `${infer FirstLetter}${string}`
  ? FirstLetter
  : never;

As a function declaration this may look like:

declare function firstLetter<Letters extends string>(
  string: Letters,
): Letters extends `${infer FirstLetter}${string}`
  ? FirstLetter
  : never;

Solution

  • Frustratingly, the type 'test'[0] isn’t 't', it’s string. (Likewise, 'test'['length'] isn’t 4, it’s number.) Clearly, string literal types are not as sophisticated in Typescript as tuple types are (both [0] and ['length'] would work as expected for ['t', 'e', 's', 't']). There is an open issue about this limitation.

    Like you, I viewed `${infer H}${infer T}` with some doubt. There’s nothing in the docs that says it will always match exactly one character for the first H and the rest in T. There is, however, a statement in the original pull request that describes exactly this behavior, saying:

    A placeholder immediately followed by another placeholder is matched by inferring a single character from the source.

    Also, in another comment, Anders says:

    In general, immediately adjacent placeholders are really only useful for taking strings apart one character at a time.

    suggesting that this is intended behavior that can be relied upon. I would like to find it in the docs, but given what we have, `${infer H}${string}` is probably safe, and certainly the best available situation so long as it is safe.

    But if we don’t use that, there are still options. Not great ones, but they exist. For instance, an easy improvement on Oleksandr’s answer would be

    function firstLetter<S extends Speed>(speed: S):
      S extends 'fast' ? 'f' :
      S extends 'medium' ? 'm' :
      S extends 'slow' ? 's' :
      never {
        // implementation
    }
    

    See in Playground

    This will return precisely the first letter of whatever Speed(s) you give. So firstLetter('fast') won’t have a return type of SpeedShort, it’ll have a type of f. And (speed: 'fast' | 'slow') => firstLetter(speed) will have a return type of 'f' | 's'.

    More importantly, this perhaps suggests a more significant improvement:

    type FirstLetterOf<S extends string> =
      string extends S ? string : // case where S is just string, not a literal type
      S extends `a${string}` ? 'a' :
      S extends `b${string}` ? 'b' :
      S extends `c${string}` ? 'c' :
      S extends `d${string}` ? 'd' :
      S extends `e${string}` ? 'e' :
      S extends `f${string}` ? 'f' :
      S extends `g${string}` ? 'g' :
      S extends `h${string}` ? 'h' :
      S extends `i${string}` ? 'i' :
      S extends `j${string}` ? 'j' :
      S extends `l${string}` ? 'l' :
      S extends `m${string}` ? 'm' :
      S extends `n${string}` ? 'n' :
      S extends `o${string}` ? 'o' :
      S extends `p${string}` ? 'p' :
      S extends `q${string}` ? 'q' :
      S extends `r${string}` ? 'r' :
      S extends `s${string}` ? 's' :
      S extends `t${string}` ? 't' :
      S extends `u${string}` ? 'u' :
      S extends `v${string}` ? 'v' :
      S extends `w${string}` ? 'w' :
      S extends `x${string}` ? 'x' :
      S extends `y${string}` ? 'y' :
      S extends `z${string}` ? 'z' :
      string;
    
    function firstLetter<S extends string>(s: S): FirstLetterOf<S> { /*…*/ }
    

    See in Playground

    This will extract the first letter... as long as we define “letter” as a-z. You can, of course, extend it... but only up to a point. Typescript has a rather low nested condition limit, and that’s already 27 layers deep. Add uppercase letters and you’re up to 53.

    A common solution to nested condition issues in situations like these is to use a mapping type, and walk through that rather than nesting all the possibilities. That is, this:

    type Letter =
      | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
      | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
      ;
    
    type FirstLetterOf<S extends string> =
      string extends S ? string : // case where S is just string, not a literal type
      {
        [L in Letter]: S extends `${L}${string}` ? L : never;
      }[Letter];
    
    function firstLetter<S extends string>(s: S): FirstLetterOf<S> { /*…*/ }
    

    See in Playground

    Now, we no longer have nested conditions, we just have one condition that we run for each Letter. Now it’s relatively easy to add letters, and while there’s still a limit on how many we can add, it’s now very large.

    This still has a problem: if you put in a string literal that doesn’t start with a Letter, the turn type is never. That’s not right; it should probably be string (as in, our type isn’t smart enough to narrow down which string we’ll end up with but we’re going to end up with one). Fixing that... looks nasty, but it remains performant and safe from nesting limits:

    type Letter =
      | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
      | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
      ;
    
    type FirstLetterOf<S extends string> =
      string extends S ? string : // case where S is just string, not a literal type
      {
        [L in Letter]: S extends `${L}${string}` ? L : never;
      }[Letter] extends infer L
        ? [L, never] extends [never, L]
          ? string
          : L
        : never;
    
    function firstLetter<S extends string>(s: S): FirstLetterOf<S> { /*…*/ }
    

    See in Playground

    The extends infer L business effectively saves the result of the type in type L, and then [L, never] extends [never, L] is a way to check if L is never—that is, none of our Letters matched. If so, our type is string instead. If not, we just go with L, since that means a Letter matched and that’s what we want to use. The last : never is there as the “false case” for the condition blah blah extends infer L, which can’t ever be false since we infer L to be whatever blah blah actually is, so of course blah blah extends it. The syntax here is... weird, but it works. We do have a few layers of nested conditions here, but it’s a fixed number that doesn’t change no matter how many Letters we add, so that’s fine.