Typescript typ predikat mappat genom till tuple-element


Överväg en union av två typer med en typ-predikat

type Maybe<T> = T | Error
function isValid<T>(value: Maybe<T>): value is T {
  return !(value instanceof Error)
}

Låt oss ha kod med flera beräknade Maybe-värden och vi vill ha en typ-predikat som kortfattat kontrollerar att alla inte är ett Error:

const a: Maybe<string> = ... // some function returning Maybe<string>
const b: Maybe<number> = ...
...

if (![a, b, /*and more*/].every(isValid)) {
  return;
}
const realA: string = a;
//Type 'Maybe<string>' is not assignable to type 'string'.
//  Type 'Error' is not assignable to type 'string'.(2322)

lekplats

Trots every(isValid) kan kompilatorn inte härleda att varken a, b,... längre är en Maybe.

Självklart kunde jag skriva

if (!isValid(a) && !isValid(b) && ...) {
  return;
}

för att hjälpa kompilatorn på traven. Men jag har 5 till 10 a och b och är i allmänhet nyfiken på om det finns en lösning. Har försökt

const data = [a, b].filter(isValid)

men detta skapar en blandad arraytyp som (string | number)[], så att en data[0] fortfarande inte är entydigt en string.

Så varken every eller filter klarar tricket att låta kompileraren ta bort Maybe efteråt, men jag undrar om det finns ett sammanfattande, "loopsigt" sätt som undviker en besvärlig if(!isValid(a)...-sekvens.

Svar #1

Det finns för närvarande inget sätt att använda en anpassad typvaktfunktion för att bara avgränsa innehållet eller medlemmarna i ett värde.

Anpassade typvaktfunktioner, som returnerar ett typpredikat av formen arg is Type eller this is Type, begränsar endast den uppenbara typen av det specifika argument som motsvarar arg eller objektet som motsvarar this. Eventuella effekter av begränsning skulle endast ses genom efterföljande åtkomst av samma värde. Anpassade typvaktfunktioner påverkar inte separat de uppenbara typerna av värden som tilldelats medlemmar av arg eller this. Till exempel:

declare function g(
  x: { a: RegExp | Date }
): x is { a: Date };
const a = Math.random()<0.5 ? new Date() : new RegExp("")
//    ^? const a: RegExp | Date
const x = { a };
//    ^? const x: { a:RegExp | Date; }
if (g(x)) {
  x.a;
  //^? (property) a: Date
  a;
//^? const a: RegExp | Date
}

I ovanstående exempel har anropet av g(x) effekten att begränsa den uppenbara typen av x till {a: Date}, och därigenom begränsa den uppenbara typen av x.a till Date. Men a, en referens till samma värde som x.a, har inte begränsats.

Det betyder att det i princip är värdelöst att använda en egen typkontrollfunktion på ett anonymt objektliteral eller ett arrayliteral:

if (g({ a })) {
  a;
  //^? const a: RegExp | Date
  ({ a }).a
  //      ^? (property) a: RegExp | Date
}

Objektsliteralen { a } kan inte nås igen; den är anonym. Att skriva { a } en andra gång ger oss bara ett nytt objekt. Och vi vet att a själv inte blir begränsad. Så vi är fast.

Det finns en öppen funktionsförfrågan på microsoft/TypeScript#46184 som ber om stöd för att begränsa objektinnehåll (specifikt de destrukturerade variablerna i objektliteraler; förmodligen även för arrayliteraler), men för tillfället är det inte en del av språket.

--

Du försökte begränsa ett arrayliteral [a, b, ⋯] med typskydds-anropssignaturen för every()-metoden, men som vi just har sett skulle detta inte ha någon effekt:

const now = new Date().getTime();
const a: Maybe<string> = now > 1 ? 'a' : new Error;
const b: Maybe<number> = now > 1 ? 1 : new Error;
const c: Maybe<null> = now > 1 ? null : new Error;
if ([a, b, c].every(isValid)) {
  a.toUpperCase(); // error, Maybe<string> 😢
}

Okej, vad händer om vi först tilldelar array-litteralen till en variabel och sedan får åtkomst till medlemmarna genom index? Det löser problemet med att den är anonym:

const arr = [a, b, c];
// const arr: (string | number | Error | null)[]
if (arr.every(isValid)) {
  const newA = arr[0]; // const newA: string | number | null
}

Så, vi får avsmalning, hurra. Typ. Tyvärr är arrayen arr av typen Maybe<string | number | null>[]. Så även om vi har begränsat arr[0] till string | number | null, har vi helt tappat bort ordningen på indatorna. Det tar oss till vårt nästa problem:


För att hålla reda på ordningen på arr:s element skulle vi behöva att den istället hade en tuppeltyp. Och every fungerar inte som en elementvis typkontroll på tupler:

const tup = [a, b, c] as const;
// const tup: readonly [Maybe<string>, Maybe<number>, Maybe<null>]
if (tup.every(isValid)) {
  tup // const tup: readonly [Maybe<string>, Maybe<number>, Maybe<null>] 😟
}

Och det finns inget sätt att åtgärda det. TypeScript saknar för närvarande den uttrycksfullhet som krävs för att beskriva hur man utför elementvisa typkontroller på tuppler. Det skulle kräva något sätt att tala om en godtycklig generisk typ F på typenivå, kanske som:

// NOT VALID TS, DO NOT TRY THIS
function every<T extends any[], F extends 🤷♂️>(
  arr: T, cb: <U extends T[number]>(v: U, i: number, a: T) => value is F<U>
): {[I in keyof T]: F<T[I]>}

Men du kan inte göra det. F är en generisk typparameter som beter sig som en specifik typ; du kan inte skapa en instans av den med sina egna typargument som F<U> eller F<T[I]>. Det finns ett länge öppet problem vid microsoft/TypeScript#1213 men det är inte en del av språket. Det finns olika sätt att försöka simulera/koda sådana typer, men de är inte tillräckligt användarvänliga för att rekommenderas här.

Se Mappa tuppel-typat värde till annat tuppel-typat värde utan konverteringar för mer information om ett liknande problem.

Så vi kommer att behöva ge upp på att använda någon slags allmän typvaktmappare som tar isValid som inmatning, och istället gå vidare och hårdkoda isValid() där.


Om vi vill använda en array kan vi skriva

function isEveryElementValid<T extends readonly any[]>(
  values: { [K in keyof T]: Maybe<T[K]> }): values is T {
  return values.every(isValid);
}

och sedan använda det som

if (isEveryElementValid(tup)) {
  const realA = tup[0]; // const realA: string
  const realB = tup[1]; // const realB: number
  const realC = tup[2]; // const realC: null
}

Eller om vi vill använda ett objekt kan vi skriva

function isEveryPropertyValid<T extends object>(
  values: { [K in keyof T]: Maybe<T[K]> }): values is T {
  return Object.values(values).every(isValid);
}

och sedan använda det som

const o = {a,b,c};
if (isEveryPropertyValid(o)) {
  const realA = o.a; // string
  const realB = o.b; // number
  const realC = o.c; // null
}

Lekplatslänk till kod