reactjs - Lös typdefinition med Omit och [key:string]: okänd


Jag postar koden först och förklarar efteråt:

type ExampleType = {
  a: string;
  b: boolean;
  c: () => any;
  d?: boolean;
  e?: () => any;
  [inheritsProps: string]: unknown;
  // If this ^ line over is remove, TypeNoC would work as I expect, but without a loose definition
};

type TypeNoC = Omit<ExampleType, "c">
// TypeNoC == {}

För några månader sedan sökte jag efter ett sätt att definiera React Props som tillåter vilka andra egenskaper som helst samtidigt som man fortfarande behåller endast de väsentliga definitionerna. Anledningen var att vår props-typ utökades med React.HTMLProps<HTMLDivElement| HTMLButtonElement | ...> men när man försökte använda komponenten fick man 200+ valfria egenskaper blandade med de obligatoriska egenskaperna. Det är irriterande och inte hjälpsamt, så jag hittade denna lösning: [inheritsProps: string]: unknown; vilket sade att alla andra definierade props skulle vara giltiga även om de inte är definierade i typen.

Nu arbetar jag på en komponent med en kontrollerad och okontrollerad version och jag skulle vilja ta bort vissa definitioner av den kontrollerade versionen för den okontrollerade definitionen. Okontrollerad använder den kontrollerade komponenten och försöker förenkla hur man använder den. Så jag skulle vilja ta bort några av de obligatoriska egenskaperna. Jag skulle förvänta mig att Omit<ExampleType, "c"> borde skapa en typ där c inte längre är {a,b,d,e,...} medan c fortfarande skulle kunna existera på grund av [key:string]:unknown, men det är inte fallet just nu. Det aktuella fallet är att TypeNoC inte har några egenskaper... Jag tror att det är ett fel, men jag är inte säker på om jag använder funktionen korrekt från början.

Svar #1

Du har rätt i att TypeScripts Omit<T, K>-verktygstyp egentligen inte fungerar bra när T har en indexsignatur. Jag skulle säga att detta bara är en begränsning av Omit och egentligen inte en bugg.


Definitionen av Omit<T, K> i TypeScripts bibliotek är

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

där Pick<T, K> definieras som

type Pick<T, K extends keyof T> = { [P in K]: T[P]; };

och Exclude<T, U> definieras som

type Exclude<T, U> = T extends U ? never : T

När T är en typ med endast kända nycklar som {a: string, b: number}, fungerar Omit som förväntat. Men när T också har en string-indexsignatur som ExampleType, kommer keyof T att vara unionen av string | number (varför number?) med alla kända literala nycklar som "a" och "b". Men unionen av string | number med vilken sträng- eller numerisk literal som helst som "a" eller 1 är bara string | number, eftersom sträng- eller numeriska literaler inte lägger till några nya möjligheter till string | number:

type KeyofExampleType = keyof ExampleType; // string | number

Och så blir Exclude<keyof ExampleType, "c"> Exclude<string | number, "c"> vilket, om du följer den distributiva konditionstypdefinitionen av Exclude, också bara är string | number: du kan inte uttrycka "alla strings utom för "c" i TypeScript; det är bara string.

Och det betyder att Omit<ExampleType, "c"> blir Pick<ExampleType, string | number>, vilket bara ger string/number-indexen av ExampleType och utelämnar helt alla kända nycklar:

type BadTypeNoC = Omit<ExampleType, "c">
/* type BadTypeNoC = {
    [x: string]: unknown;
    [x: number]: unknown;
} */

(detta är inte {} som du påstår i din fråga, dock... du bör dubbelkolla det eftersom jag inte kan återskapa det)


Kan Omit implementeras på ett annat sätt så att det fungerar "riktigt" för typer med indexsignaturer? (och är vi verkligen säkra på vad "riktigt" betyder? Det kan finnas specialfall). Nåväl, kanske, men med betydligt mer ansträngning och resulterande i en mycket mer komplicerad definition.

Först behöver du ett sätt att uttrycka något som keyof ExampleType som ger dig de kända bokstavliga nycklarna och inte bara de bredare string och number indextyperna. Detta kan göras med villkorlig typinferens, som nämnts i denna GitHub-issues kommentar, men det är galet/fult/överraskande:

type KnownKeys<T> = {
  [K in keyof T]: string extends K ? never : number extends K ? never : K
} extends { [_ in keyof T]: infer U } ? U : never;

type KnownPart<T> = Pick<T, KnownKeys<T>>

Så KnownKeys<ExampleType> är "a" | "b" | "c" | "d" | "e", och KnownPart<ExampleType> är bara ExampleType-typen utan dess indexsignaturer. På samma sätt kan du ta fram nycklarna för indexsignaturer och delen med indexsignatur:

type IndexableKeys<T> = {
  [K in keyof T]: string extends K ? K : number extends K ? K : never
} extends { [_ in keyof T]: infer U } ? U : never;
type IndexPart<T> = Pick<T, IndexableKeys<T>>

Slutligen kan du skapa en version av Omit<T, K> som delar upp T i dess KnownPart och dess IndexPart och utför nyckelutdelningen på varje del, och sedan sammanfogar dem igen:

type Omitʹ<T, K extends PropertyKey> = 
  Omit<KnownPart<T>, K> & Omit<IndexPart<T>, K> extends
  infer O ? { [P in keyof O]: O[P] } : never;

Det ger det förväntade TypeNoC, tror jag:

type TypeNoC = Omitʹ<ExampleType, "c">
/* type TypeNoC = {
    [x: string]: unknown;
    a: string;
    b: boolean;
    d?: boolean | undefined;
    e?: (() => any) | undefined;
} */

Om du vill använda något som Omitʹ istället för Omit i din kod, bör du gärna göra det. Men anledningen till att en sådan definition inte finns i standardbiblioteket är förmodligen att den är alldeles för komplex och har för många potentiella felpunkter. Fördelarna med att kunna hantera indexerbara typer är förmodligen inte värda det, speciellt eftersom indexerbara typer är mindre vanliga.

Lekplatslänk till kod

Svar #2

Som tillägg till @jcalz:s fantastiska svar ovan, vill jag bara notera att jag hittade följande i MongoDB:s källkod här:

/** TypeScript Omit (Exclude to be specific) does not work for objects with an "any" indexed type, and breaks discriminated unions @public */
export type EnhancedOmit<TRecordOrUnion, KeyUnion> = string extends keyof TRecordOrUnion
  ? TRecordOrUnion // TRecordOrUnion has indexed type e.g. { _id: string; [k: string]: any; } or it is "any"
  : TRecordOrUnion extends any
  ? Pick<TRecordOrUnion, Exclude<keyof TRecordOrUnion, KeyUnion>> // discriminated unions
  : never;

som används i samma fil för saker som:

export type WithId<TSchema> = EnhancedOmit<TSchema, '_id'> & { _id: InferIdType<TSchema> };

och fungerar lika bra för unknown-typen.

Relaterade artiklar