Typescript: Generisk type «ekstraher nøkler med verdi av type X» oppfører seg ikke som forventet
Jeg har definert følgende generiske type som henter ut fra en type T de strenge nøklene hvis verdi er et tall :
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ?
K
: never
: never
}[keyof T];
Jeg prøver å bruke denne typen i en generisk funksjon som følger :
function setNumberField<T>(item: T, field: StringKeysMatchingNumber<T>): void {
item[field] = 1;
}
Men linjen item[field]=1; gir en feil med Type 'number' kan ikke tilordnes til type 'T[StringKeysMatchingNumber<T>]'.
Jeg har prøvd noen forskjellige ting, for eksempel å begrense den generiske typen T i funksjonen til en type som eksplisitt inneholder noen strengnøkler med verdienummer, men dette hjalp ikke.
Kan alle se hva problemet er? Her er en TS-lekeplass med kodeeksemplet og noen flere detaljer: https://www.typescriptlang.org/play?#code/C4TwDgpgBAKhDOwbmgXigbwLACgpQEMAuKAOwFcBbAIwgCcAaXfagfhIpvqbygGMSiOgEtSAcx74AJhyq06uAL65cAelVQAgvCgQAHpD7AIU2AmABpCCB3oARATtQAPlDtS7uUJDOIrNqHQAZWARcX94AFkCYD4AC1ExADk5egAeOERkSAA+FRxvaBCwsQjo2ITxFK46DJzAzGYoAG0LKFEoAGtrAHsAM1gAXQBadig2-WNSKR0hRKhWJvwYVsHdPSmZslS6BaX8cf38DggAN3p9k-OFHEVm7pB+oYBufL7yUiNhHtIoeAhgNV5AAxYQQAA2UjqAAphMZKCQYAwoH0wZCSMVEmUYvFEkD0jAcgBKEinHrCUzYXhwiCUZqoiFSNboACMr1uQA
Svar #1
T i setNumberField er en svart boks. Ingen vet, ikke engang du, om T har en nøkkel med numerisk verdi eller ikke. Det finnes ingen passende begrensning. setNumberField lar deg til og med gi en primitiv verdi som første argument. Det betyr at inne i funksjonskroppen er ikke TS klar over at item[field] alltid er en numerisk verdi. Men TS er klar over det under funksjonskallet. Så funksjonen har to nivåer av typing. Det ene er funksjonsdefinisjonen, når TS ikke klarer å gjette T-typen, og det andre er under funksjonskallet, når TS er klar over T-typen og kan utlede den.
Den enkleste måten å gjøre det på er å unngå mutasjon. Du kan returnere et nytt objekt. Vurder dette eksempelet:
type TestType = {
a: number,
b?: number,
c: string,
d: number
}
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ?
K
: never
: never
}[keyof T];
const setNumberField = <
Item,
Field extends StringKeysMatchingNumber<Item>
>(item: Item, field: Field): Item => ({
...item,
[field]: 1
})
declare let foo: TestType
// {
// a: number;
// b: string;
// }
const result = setNumberField({ a: 42, b: 'str' }, 'a')
Lekeplass
Vennligst husk at TypeScript ikke liker mutasjoner. Se artikkelen min
Hvis du fortsatt vil mutere argumentet ditt, bør du overbelaste funksjonen din.
type TestType = {
a: number,
b?: number,
c: string,
d: number
}
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ?
K
: never
: never
}[keyof T];
function setNumberField<Item, Field extends StringKeysMatchingNumber<Item>>(item: Item, field: Field): void;
function setNumberField(item: Record<string, number>, field: string): void {
item[field] = 2
}
declare let foo: TestType
const result1 = setNumberField({ a: 42, b: 'str' }, 'a') // ok
const result2 = setNumberField({ a: 42, b: 'str' }, 'b') // expected error
Lekeplass
Funksjonsoverlasting er ikke så strikt. Som du kanskje har lagt merke til, tillater denne funksjonstype-definisjonen function setNumberField(item: Record<string, number>, field: string) at du kun bruker objekter der alle verdier er tall. Men dette er ikke tilfelle. Dette er grunnen til at jeg har overloadet denne funksjonen med et annet lag. Den nederste brukes for funksjonskroppen. Den øverste, med StringKeysMatchingNumber, kontrollerer funksjonsargumentene.
OPPDATERING
Hvorfor det å legge til en begrensning som
T extends Record<string, number>ikke er nok til å gjøre TS klar over typen tilitem[field]
Vurder dette:
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: K extends string ?
T[K] extends number ? // This line does not mean that T[K] is equal to number
K
: never
: never
}[keyof T];
Denne linjen T[K] extends number betyr at T[K] er en subtype av number. Det kan være number & {__tag:'Batman'}. Husk også at StringKeysMatchingNumber kan returnere never og number kan ikke tildeles til never:
declare let x: never;
x = 1 // error
Vær oppmerksom på at å kalle StringKeysMatchingNumber med statisk argument som {foo: 42} gir det forventede resultatet "foo":
type Result = StringKeysMatchingNumber<{ foo: 42 }> // foo
Men å løse StringKeysMatchingNumber inne i en funksjonskropp er en helt annen historie. Hold musen over Result inne i funksjonen Se eksempel:
function setNumberField<
T extends Record<string, number>,
Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
type Result = StringKeysMatchingNumber<T> // resolving T inside a function
const value = item[field];
item[field] = 1; // error
value.toExponential // ok
}
item[field] løses til T[Field], det er ikke en number-type. Det er en subtype av number-typen. Du har fortsatt lov til å kalle toExponential. Det er veldig viktig å forstå at item[field] er en type som har alle egenskapene til number-typen, men som også kan inneholde noen andre egenskaper. number er ikke primitivt fra TS sitt synspunkt.
//"toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
type NumberKeys = keyof number
Se dette:
function setNumberField<
T extends Record<string, number>,
Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
let numSupertype = 5;
let numSubtype = item[field]
numSupertype = numSubtype // ok
numSubtype = numSupertype // expected error
}
numSubtype kan tildeles numSupertype. item[field] kan tildeles til enhver variabel med number-type, mens number ikke kan tildeles item[field].
Avsluttende spørsmål Er måten du vil tilordne et nummer til item[field] trygg nok når det gjelder typer?
type StringKeysMatchingNumber<T> = {
[K in keyof T]-?: T[K] extends number ? K : never
}[keyof T];
function setNumberField<
T extends Record<string, number>,
Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
item[field] = 2
}
type BrandNumber = number & { __tag: 'Batman' }
declare let brandNumber: BrandNumber
type WeirdDictionary = Record<string, BrandNumber>
const obj: WeirdDictionary = {
property: brandNumber
}
setNumberField(obj, 'foo')
setNumberField forventer et ordboksobjekt hvor hver verdi utvider number-typen. Det betyr at verdien kan være number & { __tag: 'Batman' }. Jeg vet, det er rart fra et utviklerperspektiv, men ikke fra et typeperspektiv. Det er bare en subtype av number og denne teknikken brukes for å simulere nominale typer. Hva skjer hvis du tildeler 2 til item[field] uten feil? Etter å ha kalt denne funksjonen, forventer du at hver verdi er BrandNumber, men det vil ikke være tilfelle.
Så, TypeScript gjør en god jobb her :D
Du kan finne mer informasjon om inferens av funksjonsargumenter i artikkelen min
