generisk variasjon - TypeScript: Løsninger rundt usikkerhet i en Map `getOrCreate`-hjelpefunksjon
Jeg har en hjelpefunksjon for å hente en oppføring fra et kart, og legge den til hvis den ikke allerede er til stede.
export function mapGetOrCreate<K, V>(map: Map<K, V>, key: K, valueFn: (key: K) => V): V {
let value = map.get(key);
if (value === undefined) {
value = valueFn(key);
assert(value !== undefined);
map.set(key, value);
}
return value;
}
(Full kode i TS-lekeplass)
TypeScripts usikkerhet rundt generisk typevariasjon ender opp med å forårsake dette praktiske problemet for meg:
type A = {a: number};
type B = A & {b: string};
type C = B & {c: boolean};
declare function createA(): A;
declare function createB(): B;
declare function createC(): C;
function f(m: Map<string, B>, element: Base) {
m.set('1', createA()); // TS error (good)
m.set('1', createB());
m.set('1', createC());
mapGetOrCreate(m, '1', createA); // missing error!
mapGetOrCreate(m, '1', createB);
mapGetOrCreate(m, '1', createC);
}
Jeg har funnet ut at det å legge til en annen typeparameter (V2) i funksjonssignaturen «fikser» problemet:
export function mapGetOrCreate2<K, V, V2 extends V>(map: Map<K, V>, key: K, valueFn: (key: K) => V2): V {
let value = map.get(key);
if (value === undefined) {
value = valueFn(key);
assert(value !== undefined);
map.set(key, value);
}
return value;
}
Det er fortsatt usikkert, men i det minste kan ikke TypeScript automatisk tolke typene, noe som er en liten forbedring.
Spørsmål :
- Finnes det en bedre måte å oppnå det jeg gjør på?
- Finnes det måter hvor
mapGetOrCreate2er dårligere enn den originale?
Svar #1
Du leter etter noninferential type parameter usage som forespurt i microsoft/TypeScript#14829. Det finnes ingen offisiell støtte for dette, men det finnes flere teknikker for å oppnå denne effekten, en av dem er den du allerede bruker.
Bare for å være klar for alle som kommer over dette spørsmålet senere, er det usunne her at TypeScript tillater Map<K, U> å kunne tilordnes til Map<K, T> når enn U kan tilordnes til T:
function technicallyUnsound<K, T, U extends T>(mapU: Map<K, U>) {
const mapT: Map<K, T> = mapU;
}
Dette er faktisk helt rimelig så lenge du bare leser fra kartene, men når du skriver til dem kan du ende opp med problemer:
function technicallyUnsound<K, T, U extends T>(mapU: Map<K, U>, k: K, t: T) {
mapU.set(k, u); // error, as desired
const mapT: Map<K, T> = mapU;
mapT.set(k, t); // no error, uh oh
}
const m = new Map<string, Date>();
technicallyUnsound(m, "k", {});
m.get("k")?.getTime(); // compiles okay, but
// RUNTIME ERROR: u is not defined
Dette er bare slik TypeScript er; det har et sett med nyttige funksjoner, som objektsmutabilitet, subtyping, aliasing og metod bivarians, som gir økt utviklerproduktivitet, men kan brukes på usikre måter. Uansett, se dette SO-svaret for mer informasjon om slike sikkerhetsproblemer.
Det finnes egentlig ingen måte å fullstendig forhindre dette på; uansett hva du gjør, selv om du kan styrke mapGetOrCreate(), kan du alltid bruke slik aliasing for å komme rundt det:
mapGetOrCreate2(m, "", createA) // error, but
const n: Map<string, A> = m;
mapGetOrCreate2(n, "", createA) // no error
Med det forbeholdet i bakhodet, hva er alternativene for å hardne mapGetOrCreate()?
Det virkelige problemet du har med mapGetOrCreate() er TypeScripts generiske typeparameter-inferens algoritme. La oss forenkle mapGetOrCreate() til en funksjon g() der vi glemmer nøkkeltypen K (bruker bare string). I følgende kall:
declare function g<T>(map: Map<string, T>, valueFn: (key: string) => T): T;
g(m, createA); // no error, not good
// function g<A>(map: Map<string, A>, valueFn: (key: string) => A): A
Kompilatoren utleder at typeparameteren T bør spesifiseres av typen A, fordi valueFn returnerer en A, og en Map<string, B> anses også som en gyldig Map<string, A>.
Ideelt sett vil du at kompilatoren skal utlede T bare fra map, og deretter sjekke at valueFn kan tildeles til (key: string) => T for den T. I valueFn vil du bare bruke T og ikke utlede det.
Med andre ord, du ser etter ikke-innferensielt typeparameterbruk, som forespurt i microsoft/TypeScript#14829. Og som jeg sa i begynnelsen, finnes det ingen offisiell måte å gjøre dette på.
La oss se på de uoffisielle måtene:
En uoffisiell måte er å bruke en ekstra typeparameter U som er begrenset til den opprinnelige typeparameteren T. Siden U vil bli utledet separat fra T, vil det se "ikke-utledende" ut fra synspunktet til T. Dette er det du har gjort med mapGetOrCreate2:
declare function g<T, U extends T>(map: Map<string, T>, valueFn: (key: string) => U): T;
g(m, createA); // error! A is not assignable to B
// function g<B, B>(map: Map<string, B>, valueFn: (key: string) => B): B
g(m, createB); // okay
g(m, createC); // okay
En annen uoffisiell måte er å skjære tvers av de «ikke-slutningsbaserte» stedene med {}, som «reduserer prioriteten» til det slutningsstedet. Dette er mindre pålitelig og fungerer kun for typer X der X & {} ikke snevrer inn X (så X ikke kan ha undefined eller null i seg), men det fungerer også for dette tilfellet:
type NoInfer<T> = T & {};
declare function g<T>(map: Map<string, T>, valueFn: (key: string) => NoInfer<T>): T;
g(m, createA); // error! A is not assignable to type NoInfer<B>
// function g<B>(map: Map<string, B>, valueFn: (key: string) => NoInfer<B>): B
g(m, createB); // okay
g(m, createC); // okay
Og den siste metoden jeg kjenner til er å bruke det faktum at evaluering av betingede typer blir utsatt for foreløpig uavklarte typeparametere. Så NoInfer<T> vil til slutt evaluere til T, men dette vil skje etter at typeinferens har funnet sted. Og det fungerer også her:
type NoInfer<T> = [T][T extends any ? 0 : never];
declare function g<T>(map: Map<string, T>, valueFn: (key: string) => NoInfer<T>): T;
g(m, createA); // error! A is not assignable to type B
// function g<B>(map: Map<string, B>, valueFn: (key: string) => B): B
g(m, createB); // okay
g(m, createC); // okay
Alle tre disse tilnærmingene er omveier og fungerer ikke i alle tilfeller. Du kan lese gjennom ms/TS#14829 hvis du er interessert i detaljer og diskusjon om det. Mitt hovedpoeng her er at hvis teknikken din fungerer for dine bruksområder, så er den sannsynligvis grei, og jeg kjenner ikke til noen åpenbart bedre teknikk.
Den eneste måten jeg ville sagt at en modifisert versjon er verre enn originalen på, er at den er mer komplisert og krever mer testing. Problemet du prøver å unngå ser faktisk ikke ut til å oppstå veldig ofte i praksis (noe som er grunnen til at metodens bivarians er en del av språket); siden du faktisk støter på problemet, bør du sannsynligvis faktisk løse det, slik at den ekstra kompleksiteten er verdt det. Men siden slik forsterkning er fundamentalt umulig i møte med et usikkert typesystem, vil det raskt komme et punkt med synkende avkastning, etter hvilket det er bedre å bare omfavne usikkerheten og skrive noen mer defensive runtime-sjekker, og gi opp forsøket på å lage et område med ren sikkerhet.
Lenke til kode på lekeplass
