Lag en TypeScript-type som representerer den første bokstaven i en strengtype


Jeg har en funksjon som returnerer den første bokstaven i strengen som blir sendt til den. I mitt tilfelle vet jeg hvilke mulige verdier som finnes, anta enten hardkodet eller gjennom en generisk, og jeg vil at funksjonens returtype skal være nøyaktig den bokstaven som returneres, slik at jeg kan sende dette videre til senere funksjoner.

Jeg har faktisk funnet en ganske ufordelaktig måte å gjøre det på, men jeg har en følelse av at det ikke er stabilt og kanskje ikke fungerer i fremtidige versjoner av TypeScript, siden $ {infer FirstLetter} teknisk sett kunne representere et hvilket som helst antall tegn… det bare så skjer at TypeScript for øyeblikket bare finner det første:

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

Som en funksjonsdeklarasjon kan dette se slik ut:

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

Svar #1

Irriterende nok er typen 'test'[0] ikke 't', det er string. (På samme måte er 'test'['length'] ikke 4, det er number.) Det er tydelig at strenglitteraltyper ikke er like sofistikerte i Typescript som tuple-typer (både [0] og ['length'] ville fungere som forventet for ['t', 'e', 's', 't']). Det finnes en åpen sak om denne begrensningen.

Som deg var jeg skeptisk til `$ {infer H}$ {infer T}`. Det står ingenting i dokumentasjonen som sier at det alltid vil matche nøyaktig ett tegn for den første H og resten i T. Det finnes derimot en uttalelse i den opprinnelige pull-forespørselen som beskriver nettopp denne oppførselen, og sier:

En plassholder som umiddelbart følges av en annen plassholder, matches ved å utlede et enkelt tegn fra kilden.

Også, i en annen kommentar, sier Anders:

Generelt sett er umiddelbart tilstøtende plassholdere egentlig bare nyttige for å ta strenger fra hverandre ett tegn om gangen.

som antyder at dette er ønsket oppførsel som man kan stole på. Jeg vil gjerne finne det i dokumentasjonen, men gitt det vi har, er `$ {infer H}$ {string}` sannsynligvis trygt, og absolutt den beste tilgjengelige løsningen så lenge det er trygt.

Men hvis vi ikke bruker det, finnes det fortsatt alternativer. Ikke gode alternativer, men de eksisterer. For eksempel, en enkel forbedring av Oleksandrs svar ville være

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

Se i lekeplassen

Dette vil returnere nøyaktig den første bokstaven av hva enn Speed(s) du gir. Så firstLetter('fast') vil ikke ha en returtype på SpeedShort, den vil ha en type på f. Og (speed: 'fast' | 'slow') => firstLetter(speed) vil ha en returtype på 'f' | 's'.

Enda viktigere, antyder dette kanskje en mer betydelig forbedring:

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> { /*…*/ }

Se i lekeplassen

Dette vil hente ut den første bokstaven... så lenge vi definerer «bokstav» som a-z. Du kan selvfølgelig utvide det... men bare opp til et visst punkt. Typescript har en ganske lav grense for nested betingelser, og dette er allerede 27 lag dypt. Legg til store bokstaver, og du er oppe i 53.

En vanlig løsning på problemer med nestede betingelser i situasjoner som dette er å bruke en kartleggingstype, og gå gjennom den i stedet for å nest alle mulighetene. Det vil si, dette:

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> { /*…*/ }

Se i lekeplassen

Nå har vi ikke lenger nestede betingelser, vi har bare én betingelse som vi kjører for hver Letter. Nå er det relativt enkelt å legge til bokstaver, og selv om det fortsatt er en grense for hvor mange vi kan legge til, er den nå veldig stor.

Dette har fortsatt et problem: hvis du skriver inn en strengliteral som ikke starter med en Letter, blir turn-typen never. Det er ikke riktig; det burde sannsynligvis være string (som i, typen vår er ikke smart nok til å begrense hvilken streng vi ender opp med, men vi kommer til å ende opp med en). Å fikse det... ser stygt ut, men det forblir effektivt og trygt fra nestingsgrenser:

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> { /*…*/ }

Se i lekeplassen

Det extends infer L-trikset lagrer effektivt resultatet av typen i typen L, og deretter er [L, never] extends [never, L] en måte å sjekke om L er never—det vil si at ingen av våre Letters matchet. Hvis det er tilfelle, er typen vår string i stedet. Hvis ikke, går vi bare med L, siden det betyr at en Letter matchet, og det er det vi vil bruke. Den siste : never er der som «falsk tilfelle» for betingelsen blah blah extends infer L, som aldri kan være falsk siden vi infer L for å være hva blah blah faktisk er, så selvfølgelig extends blah blah den. Syntaksen her er... merkelig, men det fungerer. Vi har noen lag med nestede betingelser her, men det er et fast antall som ikke endrer seg uansett hvor mange Letters vi legger til, så det er greit.

Relaterte artikler