Extracting Last Character of a TypeScript String Literal

Viewed 60

Is it possible to extract the last character of a string? I'm doing with numeric digits right now so I don't need to write the whole alphabet as an enum.

I'm also somewhat confused about why what I wrote isn't working properly.

type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';

type GetFirstDigitOfStringLiteralNumber<T extends `${string}${Digit}`> = T extends `${infer D extends Digit}${string}` ? D : never;
type GetLastDigitOfStringLiteralNumber<T extends `${string}${Digit}`> = T extends `${string}${infer D extends Digit}` ? D : never;

// This is correct
type FirstCharacter = GetFirstDigitOfStringLiteralNumber<"21222">;
//    ^? "2"
// This is correct
type LastCharacter1 = GetLastDigitOfStringLiteralNumber<"12">;
//    ^? "2"
// This is incorrect
type LastCharacter2 = GetLastDigitOfStringLiteralNumber<"21222">;
//    ^? '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0'

Playground

The LastChracter2 not working is really surprising to me. What I'm guessing to be going on is TypeScript doesn't correctly figure out what the ${string} portion is, and gives up on the static analysis because of that.

But that doesn't explain to me why it doesn't simply return never in the case that it can't match the Digit portion to a specific character.

Edit:

Giving the ${string} portion an inferred name seems to cause the never result I expected, despite not changing anything else:


type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';

type GetLastDigitOfStringLiteralNumber<T extends `${string}${Digit}`> = T extends `${infer L extends string}${infer D extends Digit}` ? D : never;

// This is working
type InferLeading = GetLastDigitOfStringLiteralNumber<"12">;
//    ^? "2"

// Why is this breaking?
type InferLeading2 = GetLastDigitOfStringLiteralNumber<"112">;
//      ^? never
1 Answers

This does not work because because using ${infer L}${infer R} on a string longer than two characters will always infer the first char as L and the rest as R. So for "123456", L would be "1" and R would be "23456". Checking if R extends Digit leads to the wrong behavior if R is not a single char.

To fix this you can make the type recursive.

type GetLastDigitOfStringLiteralNumber<T extends `${string}${Digit}`> = 
  T extends `${string}${infer REST extends `${infer L}${Digit}`}` 
    ? GetLastDigitOfStringLiteralNumber<REST> 
    : T

Playground

Related