Typsäker rörimplementering i Typescript - inferensproblem
Jag stöter på ett märkligt problem med Typescript-inferens.
Jag utvecklar för närvarande en pipe-funktion, som tar en serie åtgärder där varje efterföljande åtgärd är beroende av resultatet av den föregående. Dessutom skapar jag en samling av hjälpfunktioner såsom first, som väljer det första objektet från arrayen. Här är ett nedbantat kodexempel:
function pipe<T>(x: T[], fn: ((a: T[]) => T) | T):T {
if (typeof fn === 'function') return fn(x);
return fn;
}
function first<T extends any[]>(x: T):T[0] {
return x[0];
}
const result1 = pipe(["s", "t"], first);
// ^? any
const result2 = pipe(["s", "t"], (x) => x[0]);
// ^? string
I det här specifika scenariot förväntar vi oss att result1 ska vara en string men det är any. Det är dock inget problem med result2.
Jag vet inte vad problemet är. Kan någon berätta för mig vad jag saknar?
Den fullständiga koden är ganska stor och jag har den här: TS Playground.
Jag har förenklat koden lite och idén från @jcalz översätts inte fullt ut till mitt problem. Detta är ett mer lämpligt utdrag:
function pipe<A, B>(x: A, fn1: ((a: [A]) => B) | B): B {
return null as B;
}
function first<T>(x: [T[]]): T {
return x[0][0];
}
const result1 = pipe(["s", "t"], first);
// ^? const result1: unknown
const result2 = pipe(["s", "t"], (x) => first(x));
// ^? const result2: string
Jag har slut på idéer.
Svar #1
Huvudproblemet med ditt exempel på kod är att när du anropar pipe() kan TypeScript inte korrekt härleda T från fn-inmatningen när den funktionen i sig är en generisk funktion. TypeScript saknar den typ av fullständig typinferensalgoritm för generisk typunifiering som skulle låta den "plugga in" rätt generiska typargument för fn. Detta är en generell begränsning i TypeScript, beskriven i microsoft/TypeScript#30134. För tillfället kommer all kod som förlitar sig på högre ordningens generiska typmanipulation troligen att misslyckas utan användarintervention, såsom att manuellt instansiera det relevanta inre typargumentet:
const result1 = pipe(["s", "t"], first<string[]>);
// ^?const result1: string // ^^^^^^^^^^ <- instantiate
Beroende på ditt användningsfall kan du kringgå detta genom att ändra vad kompilatorn använder för att härleda T. Den befintliga pipe()-funktionen har en "högre prioritet" härledningsplats för T vid fn. Om kompilatorn först försökte härleda T från x-indata, skulle det bete sig bättre, eftersom ["s", "t"] tydligt är av typen string[] utan några problem med generiska funktionsinstanseringar som kan störa saker.
Det vore trevligt om vi kunde säga "hej, vänligen härled T från x och inte från fn". Det vill säga, vi skulle kanske vilja markera T-förekomsterna inuti fn som en användning av typparameter utan härledning. Kanske skulle det vara
declare function pipe<T>(x: T[], fn: NoInfer<((a: T[]) => T) | T>): T;
Det finns en öppen funktionförfrågan för detta på microsoft/TypeScript#14829. För närvarande tillhandahåller språket inte direkt NoInfer<T>, men ärendet beskriver olika sätt att simulera eller emulera det.
En sak vi kan göra är att lägga till en andra typparameter U och begränsa den till den första typparametern T. Sedan använder vi U som en "icke-inferentiell" version av T. Det fungerar eftersom T och U kommer att tolkas separat, och kompilatorn kommer att kontrollera U mot T.
För pipe() kan det vara:
function pipe<T, U extends T>(x: T[], fn: (((a: U[]) => U) | U)): T {
if (typeof fn === 'function') return (fn as (a: T[]) => T)(x);
return fn;
}
Nu när vi anropar det, beter sig saker som önskat:
const result1 = pipe(["s", "t"], first);
// ^? const result1: string
Om du kollar kommer du att se att T härleds som string från ["s", "t"], och sedan är även U string. Troligtvis härleddes U först som unknown som tidigare, men det misslyckades och den föll tillbaka på begränsningen av T, som var string, och därför kontrolleras first mot (a: string[]) => string) och det lyckas.
Lekplatslänk till kod
