typescript - Hur man specificerar ett interface som måste ha minst två egenskaper av specifika typer
Detta är för alternativen i en rullgardinsmeny. Alternativen måste vardera ha en strängegenskap som fungerar som ett ID, och en annan egenskap som representerar vad som kommer att visas - i det här fallet en ReactNode, men jag bryr mig inte om vad dessa kallas, bara att det finns två egenskaper som uppfyller detta kriterium. Jag har försökt följande:
type Node = string | Array<string>;
interface SelectOption {
[id: string]: string;
[el: string]: Node;
}
Men när jag försöker detta ger Typescript följande fel: Duplicate index signature for type 'string'.
Detta ger inte ett fel:
export interface SelectOption {
[index: string]: string | Node;
}
men är för tillåtande eftersom det kommer att matcha ett objekt med bara en egenskap (och även överflödigt eftersom ReactNode inkluderar sträng).
Finns det ett sätt att specificera typer för två namnlösa egenskaper?
Svar #1
Jag föreslår starkt att du omstrukturerar din datastruktur till ett format som fungerar bra med TypeScript-typsystemet och JavaScript-runtime. Här är den struktur jag skulle förvänta mig att SelectOption har:
interface SelectOption {
idKey: string,
idValue: string,
elKey: string,
elValue: Node
}
Nu vet du exakt vad nyckeln för varje värde kommer att vara. Om du behöver bearbeta en av dessa saker kan du göra det enkelt:
function processSelectOption(selectOption: SelectOption) {
console.log("id at key " + selectOption.idKey +
" with string value \"" + selectOption.idValue + "\"");
console.log("el at key " + selectOption.elKey +
" with Node value " + JSON.stringify(selectOption.elValue));
}
processSelectOption({ idKey: "id", idValue: "xyz", elKey: "el", elValue: node });
// id at key id with string value "xyz"
// el at key el with Node value ["a","b","c"]
Jämför detta med vad du skulle behöva göra med din nuvarande datastruktur, där allt du vet är att någon nyckel har ett värde av typen string och någon annan nyckel har ett värde av typen Node:
function processSelectOption(selectOption: any) {
// for now, let's use any ---------------> ^^^
function isString(x: any): x is string { return typeof x === "string" }
function isNode(x: any): x is Node {
return ["string", "function"].includes(typeof x) ||
(Array.isArray(x) && x.every(w => ["string", "function"].includes(typeof w)));
}
function findSelectOptionData(selectOption: any) {
for (const idKey in selectOption) {
for (const elKey in selectOption) {
if (elKey === idKey) continue;
const idValue = selectOption[idKey];
if (!isString(idValue)) continue;
const elValue = selectOption[elKey];
if (!isNode(elValue)) continue;
return { idKey, idValue, elKey, elValue };
}
}
return;
}
const selectOptionData = findSelectOptionData(selectOption);
if (!selectOptionData) throw new Error("COULDN'T FIND IT");
// now selectOptionData is the same as the SelectOption I proposed above
console.log("id at key " + selectOptionData.idKey +
" with string value \"" + selectOptionData.idValue + "\"");
console.log("el at key " + selectOptionData.elKey +
" with Node value " + JSON.stringify(selectOptionData.elValue));
}
Se hur vi måste skriva runtime-tester för att identifiera string- och Node-värden, eftersom de kan existera på alla egenskaper. Och vi måste också iterera över alla par av egenskaper i selectOption för att hitta en som är en sträng och en annan som är en Nod. (Så om selectOption har 𝑛-nycklar, itererar du över O(𝑛²)-element för att identifiera egenskaperna.) Och när du har gjort allt det får du äntligen samma fyra delar av information från SelectOption-gränssnittet som jag ursprungligen föreslog:
processSelectOption({ id: "xyz", el: node });
// id at key id with string value "xyz"
// el at key el with Node value ["a","b","c"]
Och sedan, även när du gör detta, kan du få överraskande resultat:
processSelectOption({ el: "node", id: "str" });
// id at key el with string value "node"
// el at key id with Node value "str"
Eftersom string utökar Node, finns det inget sätt att titta på ett par strängegenskaper och ta reda på vilken som "ska" vara id och vilken som ska vara elementet. Så du måste göra mycket bearbetning bara för att komma till en punkt där det finns tvetydighet om vad som ska göra vad. Den tvetydigheten blir värre med fler egenskaper:
processSelectOption({ foo: 123, bar: "baz", id: "str", el: node });
// id at key bar with string value "baz"
// el at key id with Node value "str"
Utan att känna till ditt kompletta användningsscenario kan jag inte vara säker, men utifrån ser det ut som att en sådan datastruktur borde vara utesluten. Och det är bara om man tittar på körtiden.
I typ systemet finns samma konstiga tvetydighet. Språket är egentligen inte utformat för att räkna hur många egenskaper som finns och om det finns en egenskap av typen A och någon separat egenskap av typen B. Det finns definitivt ingen specifik typ som fångar detta koncept, så allt hopp om att skriva interface SelectOption {/*...*/} eller type SelectOption=... är förlorat.
Du kan uttrycka detta som en sorts begränsning på en typ. Om du har en kandidattyp T kan du skriva en generisk typ som kallas AsValidSelectOption<T> som tar in kandidattypen T och producerar en giltig SelectOption-typ som är "nära" T på ett på ett diffust definierat sätt. Om T är giltig, vill vi vara säkra på att T extends AsValidSelectOption<T>. Om T inte är giltig vill vi att AsValidSelectOption<T> ska vara något giltigt som är "nära" T så att felmeddelandena nämner vad som är fel på ett användarvänligt sätt.
Låt oss titta på det nu:
Först, låt oss skriva AtLeastTwoElements<K> som tar en union av nyckelliknande typer L och utvärderas till den unknown topptypen om det finns minst två element i K-unionen, eller annars till den never bottentypen om det finns färre än två element:
type AtLeastTwoElements<K extends PropertyKey> =
{ [P in K]: { [Q in Exclude<K, P>]: unknown }[Exclude<K, P>] }[K];
Detta är en nästlad mappad typ där vi i de inre typerna använder verktygstypen Exclude för att successivt ta bort nycklar från K. Om vi kan göra det en gång och fortfarande har nycklar kvar, finns det minst två nycklar i den unionen. AtLeastTwoElements<"a" | "b"> utvärderas till {a: {b: unknown}["b"], b: {a: unknown}["a"]}["a" | "b"] vilket är {a: unknown, b: unknown}["a" | "b"] vilket är unknown. Men AtLeastTwoElements<"a"> är {a: {}[never], b: {}[never]}["a" | "b"] vilket är {a: never, b: never}["a" | "b"] vilket är never. Och AtLeastTwoElements<never> är {}[never] vilket är never.
Sedan skriver vi ValidSelectOptionsWithKeys<K> som tar en union av nyckelliknande typer K och producerar en stor union av alla möjliga giltiga SelectOption-typer med dessa nycklar:
type ValidSelectOptionsWithKeys<K extends PropertyKey> = { [P in K]:
Record<P, Node> & { [Q in Exclude<K, P>]: Record<Q, string> }[Exclude<K, P>]
}[K] extends infer O ? O extends any ? { [P in keyof O]: O[P] } : never : never;
Det kan se komplicerat ut, men det är faktiskt ganska likt hur findSelectOptionData() fungerar ovan, genom att iterera över varje nyckel och behandla den som en Nod, och sedan iterera över varje återstående nyckel och behandla den som en sträng. Om det finns exakt två nycklar "a" | "b", detta evalueras till något som {a: {a: Nod}&{b: {b: string}}["b"], b: {b: Nod}&{a: {a: string}["a"]}}["a" | "b"], vilket är {a: {a: Nod, b: sträng}, b: {b: Nod, a: sträng}}["a" | "b"], vilket är {a: Nod, b: sträng} | {a: sträng, b: Nod}. Antalet möjligheter växer med antalet poster i K. För tre nycklar har du något som {a: Node, b: string} | {a: Nod, c: sträng} | {b: Nod, a: sträng} | {b: Nod, c: sträng} | {c: Nod, a: sträng} | {c: Nod, b: sträng. Så om K har 𝑛-element, är typen som skapas en förening av O(𝑛²)-element.
Slutligen bygger vi AsValidSelectOption<T>:
type AsValidSelectOption<T extends object> =
unknown extends AtLeastTwoElements<keyof T> ? ValidSelectOptionsWithKeys<keyof T> :
T & (
"anotherProp" extends keyof T ? { someOtherProp: Node | string } :
{ anotherProp: Node | string }
);
Om T har minst två element, då utvärderar vi ValidSelectOptionsWithKeys<keyof T>, vilket T helst bör kunna tilldelas om det är giltigt. Om T har färre än två element, då utvärderar vi T & {anotherProp: Node | string} vilket T nästan säkert inte kommer att kunna utöka, och felmeddelandet kommer att klaga på att anotherProp saknas. Åh, om du faktiskt råkade kalla din enda nyckel anotherProp, då klagar vi på someOtherProp. Det är förmodligen mycket osannolikt, men åtminstone har vi täckt alla baser.
För att testa om ett föreslaget värde av typen T utvidgar AsValidSelectOption<T>, behöver vi en generell hjälpfunktion att skicka det till, eftersom endast generiska funktioner kommer att härleda T åt oss istället för att tvinga oss att manuellt specificera det. Här är funktionen asSelectOption:
const asSelectOption =
<T extends object>(
opt: T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>
) => opt as T;
Idealiskt skulle jag vilja skriva <T extends AsValidSelectOption<T>>(opt: T) => opt, men det är en cirkulär begränsning. Istället begränsar vi bara T till object, men låter sedan opt vara av den villkorliga typen T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>. Detta tenderar att få kompilatorn att välja T som typen av opt och sedan testa det. Det är ett inferenstrick.
Så det var ganska mycket galen ansträngning för att förhoppningsvis fånga konceptet "en egenskap av typen string och någon distinkt egenskap av typen Node". Låt oss åtminstone se om det fungerar:
declare const node: Node;
const okay0 = asSelectOption({ a: "", b: node });
const okay1 = asSelectOption({ x: node, y: "" });
const okay2 = asSelectOption({ g: "", h: "" });
const okay3 = asSelectOption({ a: "", b: node, c: 123 })
const bad0 = asSelectOption({ a: "", b: 1 }); // number is not Node
const bad1 = asSelectOption({ a: node, b: node }); // error!
// Argument of type '{ a: Node; b: Node; }' is not assignable to
// parameter of type '{ a: Node; b: string; } | { b: Node; a: string; }'
const bad2 = asSelectOption({}) // Property 'anotherProp' is missing
const bad3 = asSelectOption({ a: "" }) // Property 'anotherProp' is missing
const bad4 = asSelectOption({ anotherProp: "" }) // Property 'someOtherProp' is missing
Nåväl, det är åtminstone bra. okay*-raderna kompilerar utan fel, eftersom varje objekt uppfyller din begränsning. bad*-raderna har fel av en eller annan anledning. Hurra, antar jag! Men oj, till vilken kostnad?
Så där har du det. Om du hoppar genom många galna hooper både vid kompileringstid och vid körtid, slutar du med en tvetydig, bräcklig och förvirrande implementation som upprätthåller din begränsning och hanterar (kollar anteckningar) fyra värden. Om du omstrukturerar din datastruktur för att
interface SelectOption {
idKey: string,
idValue: string,
elKey: string,
elValue: Node
}
då har du en enkel uppgift både vid kompileringstid och vid körningstid där de fyra relevanta informationsbitarna alltid finns på statiskt kända platser, och implementationerna är robusta. Kanske gör ditt användningsfall verkligen att det är mer önskvärt att hoppa genom hinder än att refaktorera, men igen, utifrån sett skulle jag vara mycket försiktig med ett projekt som har något som asSelectOption() i det.
Lekplatslänk till kod
Svar #2
Du kan förenkla AtLeastTwoElements från @jcalz svar till
type AtLeastTwoElements<K extends PropertyKey> =
{ [P in K]: Exclude<K, P> }[K]
AtLeastTwoElements<"a" | "b"> utvärderas till {a: "b", b: "a"}["a" | "b"] vilket är "a" | "b".
Men AtLeastTwoElements<"a"> är {a: aldrig}["a"] vilket är aldrig.
