Slutning av en generell tuple med spredningsuttrykk i TypeScript


(TS-versjon - 4.1)

Jeg prøver å skrive en generisk funksjon som kan akseptere et array/tuple av hvilken som helst lengde; hvert element vil ha de samme egenskapsnavnene, men forskjellige mulige typer.

type Param<X = unknown, Y = unknown> = {
  x?: X
  y: Y
}

// return type is a fixed tuple: for each element, returns x if defined, otherwise y
function func(arg: Param[]) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y )
} 

Eksempel :

func([ {x: 'string', y: 123} ])
func([ {x: 'string', y: 123}, {x: 123, y: 'string' } ])

*oppdatering: oppdatert funksjonen for å klargjøre hvorfor jeg eksplisitt bryr meg om Xene og Yene: den faktiske returtypen er en tuple hvor de enkelte elementene kan være enten X eller Y avhengig av den opprinnelige argumentet

Det jeg egentlig prøver å få frem, er at kompilatoren skal utlede lengden og elementtypen til hvert argument, slik at resultatet blir tilsvarende typen.

Jeg har prøvd et par ting

  • Lager et par hjelpere for å hente ut de nestede typene fra Param<X,Y>
type InferX<T extends [...any[]]> = {
  [K in keyof T] : T[K] extends Param<infer X, any> ? X : never
}
type InferY<T extends [...any[]]> = {
  [K in keyof T] : T[K] extends Param<any, infer Y> ? Y : never
}
  • Gi funksjonen noen typeparametere for å inkludere de nestede typene, og representere funksjonsargumentet som et spredningsuttrykk, kartlagt til disse generiske typene
// this works when I provide explicit type parameters, but won't infer them implicitly from the arguments.. assumes everything is `unknown` i.e. func<Param<unknown, unknown>[], unknown[], unknown[]>
function func<
  T extends Param[],
  X extends { [K in keyof T]: X[K] } = InferX<T>,
  Y extends { [K in keyof T]: Y[K] } = InferY<T>,
>(
  arg: [...{ [K in keyof T]: Param<X[K], Y[K]> }]
) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y ) as [
    ...{ [K in keyof T]: T[K] extends {x: X[K]} ? X[K] : Y[K] }
  ]
}

// application 1: works ok
func<[{x: string, y: number}, {x: number, y: string}]>([{x: 'string', y: 123}, {x: 123, y: 'string'}])

// application 2: doesn't work - types are unknown
func([{x: 'string', y: 123}])

// when I try to get more explicit with type T it won't compile at all- `A rest element type must be an array type.` error on the spread arg types
function func<
  T extends { [K in keyof T]: Param<X[K], Y[K]> } & any[],
  X extends { [K in keyof T]: X[K] } = InferX<T>,
  Y extends { [K in keyof T]: Y[K] } = InferY<T>,
>(
  arg: [...{ [K in keyof T]: Param<X[K], Y[K]> }] // ERROR
) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y ) as [
    ...{ [K in keyof T]: T[K] extends {x: X[K]} ? X[K] : Y[K] }
  ]
}

Lurer på om noen har noen ideer om hvordan man får inferensen til å fungere riktig/tenker jeg på dette feil?

Takk!

Svar #1

For best resultater, gjør typeparameterbestemmelse så enkel som mulig for kompilatoren

Når du kaller en generell funksjon uten å manuelt spesifisere dens typeparametere, må kompilatoren slutte seg til disse typeparametrene, ofte ut fra verdier som sendes som parametere til funksjonen. La oss si at vi har en funksjon hvis kallesignatur er

// type F<T> = ...
declare function foo<T>(x: F<T>): void;

Og vi kaller det slik:

// declare const someValue: ...
foo(someValue);

Da er kompilatorens jobb å bestemme T basert på typen til someValue og definisjonen av F<T>. Kompilatoren må prøve å invertere F, og utlede T fra F<T>. Jo enklere det er, desto mer sannsynlig er det at den utledede typeparameteren samsvarer med dine forventninger.

I ditt tilfelle har du noe som

function func<
  T extends Param[],
  X extends { [K in keyof T]: X[K] } = InferX<T>,
  Y extends { [K in keyof T]: Y[K] } = InferY<T>,
>(
  arg: [...{ [K in keyof T]: Param<X[K], Y[K]> }]
): void;

men kompilatoren klarer ikke å slutte seg til T, X og Y fra en verdi av typen [...{ [K in keyof T]: Param<X[K], Y[K]> }]. Det er rett og slett for mye typemanipulering for at kompilatoren skal klare. Du vil ha bedre hell hvis du gjør noe enklere, som

function func<T extends Param[]>(arg: T): void;

Variadiske tuplet-typer i funksjonsparametertyper gir kompilatoren et hint om å utlede tupler i stedet for bare arrays

Dette fungerer, men kompilatoren har en tendens til å anta array-typer i stedet for tuple-typer:

declare function func<T extends Param[]>(arg: T): void;
func([{ x: "", y: 1 }, { x: 1, y: "" }]) 
// T inferred as Array<{x: string, y: number} | {x: number, y: string}>

Siden du vil at T skal bli tolket som en tuple-type og ikke bare en array-type, kan du bruke variadiske tuple-typer for å få denne oppførselen, ved å endre arg: T til arg: [...T] eller arg: readonly [...T]:

declare function func<T extends Param[]>(arg: [...T]): void;
func([{ x: "", y: 1 }, { x: 1, y: "" }]) 
// T inferred as [{ x: string; y: number;}, { x: number; y: string;}]

Beregn returtypen: ingen merknad gir unknown[]

Nå er det spørsmålet om hva man skal gjøre med utgangstypen. Den er ikke void. La oss undersøke implementeringen av eksempel-koden:

function func<T extends Param[]>(arg: [...T]) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y)
} 

Hvis vi ikke annoterer returtypen, vil den bli bestemt av kompilatoren til å være unknown[]; map()-metoden for arrays beholder ikke tuple-lengder, og den kan absolutt ikke følge den høyere ordens logikken som ville produsere forskjellige typer for forskjellige indekser. (Se Mapping tuple-typed value to different tuple-typed value without casts for mer informasjon.)

Så du ville få unknown[]:

const p: Param<string, number> = (
  [{ x: "", y: 1 }, { y: 1 }, { x: undefined, y: 1 }]
)[Math.floor(Math.random() * 3)]

const result = func([
  { x: "", y: 1 },
  { y: 1 },
  { x: undefined, y: 1 },
  p
])
// const result: unknown[]

som er sant men ubrukelig for deg. Siden kompilatoren ikke kan slutte en sterk type for returverdien av map(), vil vi måtte erklære den som noe som er en funksjon av T. Men hvilken funksjon av T?


Beregn returtypen: en union av x- og y-egenskapene i hvert element

Vel, for hver numeriske indeks I, ser du på elementet T[I] og returnerer enten dets "x" egenskap av typen T[I]["x"] eller dets "y" egenskap av typen T[I]["y"]. Så et første forsøk på dette ville være å returnere foreningen av disse typene:

// here E is some element of the T array
type XorY<E> = E extends Param ? E["x"] | E["y"] : never;

function func<T extends Param[]>(arg: [...T]) {
  return arg.map(elem => 'x' in elem ? elem.x : elem.y) as
    { [I in keyof T]: XorY<T[I]> }
}

const result = func([
  { x: "", y: 1 },
  { y: 1 },
  { x: undefined, y: 1 },
  p
])
// const result: [string | number, unknown, number | undefined, string | number | undefined]

Det er bedre; vi har en tuppel på fire elementer, og typene string og number er stort sett der. Men det er noen ulemper.

En ulempe er at kompilatoren ikke innser at hvis x eksisterer, vil du definitivt få det; antakeligvis bør typen for det første elementet av result være string, ikke string | number.

En annen ting er at hvis x ikke er kjent for å eksistere, kommer typen ukjent ut. Dette er faktisk teknisk korrekt; kompilatoren kan ikke vite at typen den utleder for {y: 1} betyr at x-egenskapen definitivt mangler. Typen {y: number} betyr ikke "ingen egenskaper utenom y eksisterer"; det betyr bare "ingen kjente egenskaper bortsett fra y eksisterer". (Det vil si, objekttyper er ikke "eksakte" i betydningen som er forespurt i microsoft/TypeScript#12936.) Så kompilatoren bestemmer at T[I]['x'] er ukjent, noe som ødelegger ting. La oss gjøre det teknisk-ukorrekte-men-praktiske og si at en type som {y: 1} betyr at x mangler, og derfor ønsker vi bare typen av y som kommer ut.


Beregning av returtype: x hvis den eksisterer, y hvis den ikke gjør det, og foreningen hvis vi ikke vet

Så la oss omdefinere XorY<E>-typen fra ovenfor slik at den utfører følgende analyse på elementtypen E i arrayet:

  • hvis E har en ikke-valgfri x-egenskap av typen X, bør vi returnere X.
  • hvis E ikke har en x-egenskap, men den har en y-egenskap av typen Y, bør vi returnere Y.
  • ellers, hvis E har en valgfri x-egenskap av typen X og en y-egenskap av typen Y, så bør vi returnere X | Y.

Det burde dekke alle tilfeller (men selvfølgelig kan det være kanttilfeller, så du må teste).

Det er litt vanskelig å nøyaktig sjekke om x-egenskapen er valgfri eller ikke; se dette svaret for detaljer. Her er en måte å skrive XorY på:

type XorY<E> = E extends Param<any, infer Y> ? E['x'] extends infer X ?
  'x' extends keyof E ? {} extends Pick<E, 'x'> ? X | Y : X : Y
  : never : never

Det beregner X og Y som en funksjon av E på en merkelig måte; Y blir funnet med betinget typeinferencia, men for X indekserer jeg direkte i E; det er fordi X ikke vil inkludere undefined hvis du bruker infer, men valgfrie egenskaper er noen ganger undefined (med unntak av --exactOptionalProperties compiler-flagget). Så E['x'] er mer nøyaktig enn infer når det gjelder undefined.

Uansett, bevæpnet med X og Y, returnerer vi X | Y hvis x er en kjent nøkkel ('x' extends keyof E) og hvis den er valgfri ({} extends Pick<E, 'x'>). Hvis den er kjent, men ikke valgfri, returnerer vi X. Og hvis den ikke er kjent, returnerer vi Y.

Ok, la oss prøve det:

const result = func([
  { x: "", y: 1 },
  { y: 1 },
  { x: undefined, y: 1 },
  p
])
// const result: [string, number, undefined, string | number | undefined]
console.log(result) // ["", 1, undefined, something]

Perfekt! Det er omtrent så spesifikt som vi kunne håpe på. Igjen, det kan være spesielle tilfeller, og du bør teste for å sikre at det fungerer med dine faktiske brukstilfeller. Men jeg tror dette er så nær en løsning som jeg kan forestille meg for eksempel-koden som er gitt.

Lenke til kode på lekeplassen

Svar #2

Det finnes en alternativ versjon:

type Elem = Param;

type HandleFalsy<T extends Param> = (
  T['x'] extends undefined
  ? T['y']
  : (
    unknown extends T['x']
    ? T['y'] : T['x']
  )
)

type ArrayMap<
  Arr extends ReadonlyArray<Elem>,
  Result extends any[] = []
  > = Arr extends []
  ? Result
  : Arr extends readonly [infer H, ...infer Tail]
  ? Tail extends ReadonlyArray<Elem>
  ? H extends Elem
  ? ArrayMap<Tail, [...Result, HandleFalsy<H>]>
  : never
  : never
  : never;

type Param<X = unknown, Y = unknown> = {
  x?: X
  y: Y
}

type Json =
  | null
  | string
  | number
  | boolean
  | Array<JSON>
  | {
    [prop: string]: Json
  }


const tuple = <
  X extends Json,
  Y extends Json,
  Tuple extends Param<X, Y>,
  Arr extends Tuple[]
>(data: [...Arr]) =>
  data.map(elem => 'x' in elem ? elem.x : elem.y) as ArrayMap<Arr>

// [42, "only y"] 
const result1 = tuple([{ x: 42, y: 'str' }, { y: 'only y' }])

// [42, "only y", "100"]
const result2 = tuple([{ x: 42, y: 'str' }, { y: 'only y' }, { x: '100', y: 999999 }])

Lekeplass

For å bedre forstå hva som skjer i ArrayMap og HandleFalsy vennligst ta en titt på js-representasjonen:

const HandleFalsy = (arg: Param) => {
  if (!arg.x) {
    return arg.y
  }
  return arg.x
}

const ArrayMap = (arr: ReadonlyArray<Elem>, result: any[] = []): Array<Param[keyof Param]> => {
  if (arr.length === 0) {
    return result
  }

  const [head, ...tail] = arr;

  return ArrayMap(tail, [...result, HandleFalsy(head)])
}

Hvis du vil lære mer om inferens av funksjonsargumenter, kan du lese artikkelen min

Relaterte artikler