Typescript, slå ihop objekttyper?


Är det möjligt att slå ihop egenskaperna hos två generiska objekttyper? Jag har en funktion som liknar denna:

function foo<A extends object, B extends object>(a: A, b: B) {
    return Object.assign({}, a, b);
}

Jag skulle vilja att typen ska vara alla egenskaper i A som inte finns i B, och alla egenskaper i B.

merge({a: 42}, {b: "foo", a: "bar"});

ger en ganska konstig typ av {a: number} & {b: string, a: string}, a är dock en sträng. Den faktiska returvärdet ger rätt typ, men jag kan inte lista ut hur jag skulle skriva det uttryckligen.

Svar #1

UPPDATERING för TS4.1+

Det ursprungliga svaret fungerar fortfarande (och du bör läsa det om du behöver en förklaring), men nu när rekursiva villkorliga typer stöds, kan vi skriva merge() för att vara variadisk:

type OptionalPropertyNames<T> =
  { [K in keyof T]-?: ({} extends { [P in K]: T[K] } ? K : never) }[keyof T];

type SpreadProperties<L, R, K extends keyof L & keyof R> =
  { [P in K]: L[P] | Exclude<R[P], undefined> };

type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never

type SpreadTwo<L, R> = Id<
  & Pick<L, Exclude<keyof L, keyof R>>
  & Pick<R, Exclude<keyof R, OptionalPropertyNames<R>>>
  & Pick<R, Exclude<OptionalPropertyNames<R>, keyof L>>
  & SpreadProperties<L, R, OptionalPropertyNames<R> & keyof L>
>;

type Spread<A extends readonly [...any]> = A extends [infer L, ...infer R] ?
  SpreadTwo<L, Spread<R>> : unknown

type Foo = Spread<[{ a: string }, { a?: number }]>

function merge<A extends object[]>(...a: [...A]) {
  return Object.assign({}, ...a) as Spread<A>;
}

Och du kan testa det:

const merged = merge(
  { a: 42 },
  { b: "foo", a: "bar" },
  { c: true, b: 123 }
);
/* const merged: {
    a: string;
    b: number;
    c: boolean;
} */

Lekplatslänk till kod

ORIGINALSVAR


Den intersection-typ som produceras av TypeScripts standardbiblioteksdefinition av Object.assign() är en approximation som inte korrekt representerar vad som händer om ett senare argument har en egenskap med samma namn som ett tidigare argument. Fram till alldeles nyligen var detta dock det bästa man kunde göra i TypeScripts typsystem.

Med introduktionen av konditionella typer i TypeScript 2.8 finns det dock närmare approximationer tillgängliga för dig. En sådan förbättring är att använda typerfunktionen Spread<L,R> som definieras här, på detta sätt:

// Names of properties in T with types that include undefined
type OptionalPropertyNames<T> =
  { [K in keyof T]: undefined extends T[K] ? K : never }[keyof T];

// Common properties from L and R with undefined in R[K] replaced by type in L[K]
type SpreadProperties<L, R, K extends keyof L & keyof R> =
  { [P in K]: L[P] | Exclude<R[P], undefined> };

type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never // see note at bottom*

// Type of { ...L, ...R }
type Spread<L, R> = Id<
  // Properties in L that don't exist in R
  & Pick<L, Exclude<keyof L, keyof R>>
  // Properties in R with types that exclude undefined
  & Pick<R, Exclude<keyof R, OptionalPropertyNames<R>>>
  // Properties in R, with types that include undefined, that don't exist in L
  & Pick<R, Exclude<OptionalPropertyNames<R>, keyof L>>
  // Properties in R, with types that include undefined, that exist in L
  & SpreadProperties<L, R, OptionalPropertyNames<R> & keyof L>
  >;

(Jag har ändrat de länkade definitionerna lite; använder Exclude från standardbiblioteket istället för Diff, och omsluter Spread-typen med den inaktiva Id-typen för att göra den inspekterade typen mer hanterbar än en massa snitt).

Låt oss prova det:

function merge<A extends object, B extends object>(a: A, b: B) {
  return Object.assign({}, a, b) as Spread<A, B>;
}

const merged = merge({ a: 42 }, { b: "foo", a: "bar" });
// {a: string; b: string;} as desired

Du kan se att a i utdata nu korrekt känns igen som en string istället för string & number. Yay!


Men notera att detta fortfarande är en uppskattning:

  • Object.assign() kopierar endast räkningsbara, egna egenskaper, och typesystemet ger dig inget sätt att representera en egenskaps räkningsbarhet och ägarskap för att filtrera på. Det betyder att merge({},new Date()) kommer att se ut som typ Date för TypeScript, även om inga av Date-metoderna kommer att kopieras vid körning och resultatet i princip är {}. Detta är en hård gräns för tillfället.

  • Dessutom skiljer inte definitionen av Spread riktigt mellan saknade egenskaper och en egenskap som är närvarande med ett odefinierat värde. Så merge({ a: 42}, {a: undefined}) typas felaktigt som {a: number} när det borde vara {a: undefined}. Detta kan förmodligen fixas genom att omdefiniera Spread, men jag är inte 100% säker. Och det kanske inte är nödvändigt för de flesta användare. (Redigering: detta kan fixas genom att omdefiniera type OptionalPropertyNames<T>={ [K in keyof T]-?: ({} extends { [P in K]: T[K] } ? K : never) }[keyof T])

  • Typsystemet kan inte göra något med egenskaper som det inte känner till. declare const whoKnows: {}; const notGreat=merge({a: 42}, whoKnows); kommer att ha en utgångstyp av {a: number} vid kompileringstid, men om whoKnows råkar vara {a: "bar"} (vilket är tilldelningsbart till {}), då är notGreat.a en sträng vid körning men ett nummer vid kompileringstid. Hoppsan.

Så varnade; typen av Object.assign() som en intersection eller en Spread<> är en slags "bästa försök"-sak, och kan leda dig fel i kantfall.

Playground-länk till kod


*OBS: Id<T> är en identitetstyp och borde i princip inte göra något med typen. Någon har vid något tillfälle redigerat detta svar för att ta bort det och ersätta med enbart T. En sådan ändring är inte direkt felaktig, men den motverkar syftet... vilket är att iterera genom nycklarna för att eliminera snitt. Jämför:

type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never 

type Foo = { a: string } & { b: number };
type IdFoo = Id<Foo>; // {a: string, b: number }

Om du inspekterar IdFoo kommer du att se att snittet har eliminerats och de två beståndsdelarna har slagits ihop till en enda typ. Återigen finns det ingen verklig skillnad mellan Foo och IdFoo när det gäller tilldelningsbarhet; det är bara det att den senare är lättare att läsa i vissa situationer.

Svar #2

Uppdatering :

Tack vare kommentaren från re-gor gick jag tillbaka till detta och uppdaterade syntaxen för att vara mer tydlig om sammanslagningen.

type Merge<A, B> = {
  [K in keyof A | keyof B]: 
    K extends keyof A & keyof B
    ? A[K] | B[K]
    : K extends keyof B
    ? B[K]
    : K extends keyof A
    ? A[K]
    : never;
};

Genom att utvidga keyof-operatorn till A och B individuellt, exponeras alla nycklar.

Med hjälp av nästlade ternära typer, kontrollera först om nyckeln finns i A och B, och när den gör det, är typen A[K] | B[K].

Nästa gång, när nyckeln bara kommer från B, då är typen B[K].

Nästa, när nyckeln endast kommer från A, då är typen A[K].

Slutligen existerar nyckeln varken i A eller B och typen är never.

type X = Merge<{ foo: string; bar: string }, { bar: number }>;

>>> type X = { foo: string; bar: string | number; }

Föregående:

Jag hittade en syntax för att deklarera en typ som slår samman alla egenskaper hos två objekt.

type Merge<A, B> = { [K in keyof (A | B)]: K extends keyof B ? B[K] : A[K] };

Denna typ låter dig specificera två objekt, A och B.

Från dessa skapas en mappad typ vars nycklar härleds från tillgängliga nycklar från antingen objektet. Nycklarna kommer från keyof (A | B).

Varje nyckel mappas sedan till typen av den nyckeln genom att slå upp lämplig typ från källan. Om nyckeln kommer från B, är typen typen av den nyckeln från B. Detta görs med K extends keyof B ?. Denna del ställer frågan, "är K en nyckel från B"? För att få typen av den nyckeln, K, används en egenskapsuppslagning B[K].

Om nyckeln inte är från B, måste den vara från A, alltså är ternären fullbordad:

K extends keyof B ? B[K] : A[K]

Allt detta är omslutet i en objektnotation { }, vilket gör detta till en mappad objekttyp, vars nycklar härleds från två objekt och vars typer kartläggs till källtyperna.

Relaterade artiklar