typescript - Typinferens med metoder vs pilar


Här är lite (leksaks)kod för att skapa ett objekt från medlemmar (fält och metoder), tillsammans med alternativ som gäller för dessa medlemmar:

interface FieldOptions {
    type: 'field'; // ... etc
}

interface MethodOptions {
    type: 'method';
}

type Options<T> = {
    [K in keyof T]?: T[K] extends Function ? MethodOptions : FieldOptions;
}

function createModel<T>(members: T, options: Options<T>) {
    // do something with members and options
    return {} as T;
}

Om members inkluderar en metod (eller funktion), misslyckas typernas inferens (typerna för x och foo är okända):

const m1 = createModel({
    x: 2,
    foo() {
        return 1;
    }
},
{
    x: {type: 'field'},
    foo: {type: 'method'} // error - Type '"method"' is not assignable to type '"field"'
});

Men om members innehåller en pilfunktion fungerar typavledning som förväntat:

const m2 = createModel({
    x: 2,
    foo: () => {
        return 1;
    }
},
{
    x: {type: 'field'},
    foo: {type: 'method'} // ok
});

Observera att flaggan --strictFunctionTypes (som leder till skillnader i hur metoder och funktioner hanteras - se till exempel här) inte har någon effekt på resultatet.

Några förslag på orsaken till/hur man åtgärdar detta problem?

Länk till lekplats

Svar #1

Du har stött på en nuvarande begränsning i TypeScripts inferensförmåga, som beskrivs i microsoft/TypeScript#47599. TypeScript har ibland problem när det behöver härleda både generiska typargument och kontextuella typer. Inferensalgoritmen går till på ett visst sätt, och ibland är denna ordning inte kompatibel med den ordning som krävs för en lyckad inferens. Det har gjorts förbättringar av denna algoritm över tid, men det kommer sannolikt alltid att finnas vissa situationer där saker bryter som "inte borde".

I fallet med en metod som { foo() { ⋯ } } vill TypeScript veta typen av this-kontexten inuti metodkroppen, eftersom det i allmänhet kan påverka metodens returtyp och därmed typen av anropssignaturen. Nu, i själva verket, beror { foo() { return 1 } } faktiskt inte alls på this. (Det är inte som { foo() { return this.xxx } }) Men TypeScript undersöker för närvarande inte kroppen för att se om this kan ignoreras. Istället beslutar det att det bör vänta tills det vet tillräckligt om this för att fortsätta.

Vad som händer i ditt exempel är därför att TypeScript beslutar att typen för argumentet members inte kan bestämmas omedelbart, och därför skjuter det upp inferensen där. Det tittar istället på argumentet options. Och även om det kan härleda typen av det enkelt, kan det inte använda den typen för att korrekt härleda T, eftersom Options<T> är en mappad typ där varje egenskap vid nyckeln K är en villkorad typ som inte bevarar tillräckligt med information om T[K]. Så det slutar med att härleda T som något i stil med {x: unknown, foo: unknown} och du får problem där Options<T> är alla FieldOptions och inga MethodOptions.

Å andra sidan har en pilfunktion som { foo:() => { return 1 } } ingen this-kontext. Kroppen kan analyseras utan att behöva kontextuell typinformation. Så den ses omedelbart som typ { foo(): 1 } och T härleds från members-argumentet.


Det är det som händer. För att åtgärda det måste du ändra typerna så att TypeScript inte försöker härleda T från options alls. Det måste, ähm, "uppmuntras" att fortsätta analysera members innan det härleder T. Ett ganska enkelt sätt att göra det är att använda hjälpklassen NoInfer<T>:

function createModel<T>(members: T, options: NoInfer<Options<T>>) {
    return {} as T;
}

Nu när du kallar på det:

const m1 = createModel({
    x: 2,
    foo() {
        return 1;
    }
},
    {
        x: { type: 'field' },
        foo: { type: 'method' }
    });

Det lyckas eftersom T härleds från members som { x: number; foo(): 1; }, precis som fallet med pilfunktionen.


Om NoInfer<T> inte hade fungerat, skulle lösningarna innebära mer uttrycklig uppdelning av styrflödet i delar, såsom en byggare eller en currerad funktion, där det första anropet härleder T och sedan ett efterföljande anrop använder members.

Eller, ibland är det enda du kan göra att tvinga anropare att vara mer explicita med typer, antingen genom att manuellt instansiera den generiska typen argument

const m1a = createModel<{ x: number, foo(): 1 }>({ // here's T
    x: 2,
    foo() {
        return 1;
    }
},
    {
        x: { type: 'field' },
        foo: { type: 'method' }
    });

eller genom att manuellt specificera this-kontexten via en this-parametertyp:

const m1b = createModel({
    x: 2,
    foo(this: any) { // I don't care what `this` is
        return 1;
    }
},
    {
        x: { type: 'field' },
        foo: { type: 'method' }
    });

Vilket i princip är att skära av knuten istället för att lösa upp den.

Lekplatslänk till kod

Svar #2

Du begär två separata saker, och lite klargörande kan behövas.

När det gäller en lösning är jag inte säker på orsaken, men du kan lösa det genom att använda en högre ordnings funktion.

function createModel<T>(members: T) {
  return (options: Options<T>) => {
    return {} as any;
  };
}

Lekplats

Relaterade artiklar