Skillnad mellan argumenttyp och generisk i TypeScript


Jag försöker bygga ett händelsehanteringssystem för olika modeller med TypeScript och har stött på ett superkonstigt fel som ser ut som en bugg i TypeScript.

Låt oss säga att vi har en modell ApprovalStep som egentligen är en union:

type ApprovalStepCompleted = {
  state: 'completed',
};
type ApprovalStepBlocked = {
  state: 'blocked',
};
type ApprovalStep = ApprovalStepCompleted | ApprovalStepBlocked;

Vi vill ha specifika hanterare för varje fält i vilken som helst modell. Här är definitionen av händelsetyp och av en uppsättning lyssnare:

interface EventDao<Model>  {
  subject: Model;
}
type Listeners<Model> = Partial<{
  [k in keyof Model]: (event: EventDao<Model>) => any;
}>;

Det här ser ganska enkelt ut, eller hur? Så vi kan bara ta vilken modell som helst (som ApprovalSteps) och bygga en karta över lyssnare, på det här sättet:

const listeners: Listeners<ApprovalStep> = {
  state: (ev: EventDao<ApprovalStep>) => {
    console.log(ev);
  }
}

Men när vi försöker ringa en specifik hanterare får vi ett superkonstigt fel:

const exampleEvent: EventDao<ApprovalStep> = {
  subject: {
    state: 'completed',
  }
}

if (listeners.state) {
  // this one raises an error!
  listeners.state(exampleEvent);
}

Felet säger:

Argument of type 'EventDao<ApprovalStep>' is not assignable to parameter of type 'EventDao<ApprovalStepCompleted> & EventDao<ApprovalStepBlocked>'.
  Type 'EventDao<ApprovalStep>' is not assignable to type 'EventDao<ApprovalStepCompleted>'.
    Type 'ApprovalStep' is not assignable to type 'ApprovalStepCompleted'.
      Type 'ApprovalStepBlocked' is not assignable to type 'ApprovalStepCompleted'.
        Types of property 'state' are incompatible.
          Type '"blocked"' is not assignable to type '"completed"'.(2345)

Det som är mer konstigt är att när vi manuellt använder ApprovalStep för Listeners-typen (istället för generisk Model), fungerar det!

type Listeners<Model> = Partial<{
  [k in keyof ApprovalStep]: (event: EventDao<Model>) => any;
}>;

Hur kan detta göra någon skillnad? Model bör vara 100% lika med ApprovalStep i detta fall!

Hela exemplet kan hittas på TypeScript-lekplatsen

Svar #1

Det är inte ett fel i TypeScript men jag klandrar dig inte för att du är förvirrad. Här är vad som pågår. När du har:

  • en mappad typ som är homomorf, vilket betyder att den mappar över nycklarna i en typvariabel som {[K in keyof T]: ...}, och

  • typen över vars nycklar du mappar (T ovan) är en uniontyp, då

  • mappningen är fördelad över den unionen. Det betyder att den delar upp unionen i dess medlemmar, gör mappningen för varje och returnerar unionen av dessa resultat.

Mer uttryckligt, du har type SomeMapping<T>={[K in keyof T]: ...} och utvärderar SomeMapping<A | B | C>, du kommer att få samma resultat som SomeMapping<A> | SomeMapping<B> | SomeMapping<C>.

Jag är inte säker på om det finns någon officiell dokumentation av detta beteende, men du kan läsa microsoft/TypeScript#26063, som nämner detta och andra saker som händer med homomorfa mappade typer. (Läs delen som börjar: "Given a homomorphic mapped type")

I allmänhet ger detta beteende rimliga resultat, men ibland, som nu till exempel, kan det göra till synes konstiga saker:


Din definition av Listeners,

type Listeners<Model> = Partial<{
  [k in keyof Model]: (event: EventDao<Model>) => any;
}>;

är en homomorfiskt mappad typ (Partial<T> är själv homomorf över T, och om F<T> och G<T> båda är homomorfa över T så är även F<G<T>> det.) Och när du utvärderar Listeners<ApprovalStep> får du något liknande:

const listeners: Partial<{
    state: (event: EventDao<ApprovalStepCompleted>) => any;
}> | Partial<{
    state: (event: EventDao<ApprovalStepBlocked>) => any;
}>

vilket betyder att listeners i sig är en unionstyp, och därför är listeners.state, om det finns, en union av funktionstyper med olika argument, och kan därför endast anropas med snittet av dess argument (på grund av stödet som infördes i TypeScript 3.3 för att anropa unioner av funktioner). Och så får du det konstiga felet om hur du inte anropar det med snittet. Detta är inte vad du ville.


Lösningen här är förmodligen att förhindra att avbildningen är homomorf. Detta kan ske på flera sätt, vanligtvis genom att involvera någon form av indirektion så att kompilatorn inte tolkar avbildningen som [K in keyof T] för en typvariabel T. Du vill få något mitt emellan in och keyof där.

Jag skulle rekommendera att du använder hjälptypen Record<K, T> definierad ungefär som type Record<K extends PropertyKey, T>={[P in K]: T}. Denna typ är inte homomorf eftersom K inte direkt är keyof något. Även om du använder Record<keyof T, ...> blir den inte homomorf.

Så jag skulle skriva:

type Listeners<Model> =
  Partial<Record<keyof Model, (event: EventDao<Model>) => any>>;

Nu när du definierar listeners,

const listeners: Listeners<ApprovalStep> = ...;

dess typ är

const listeners: Partial<Record<"state", (event: EventDao<ApprovalStep>) => any>>

vilket i princip är samma som

const listeners: {
  state?: ((event: EventDao<ApprovalStep>) => any) | undefined;
}

Det är en enda objekttyp, inte en union, och state-metoden tar ett argument beroende på den fullständiga ApprovalStep-unionstypen, inte den tidigare uppdelade och återförenade funktionen. Nu borde det fungera som du förväntar dig:

if (listeners.state) {
  listeners.state(exampleEvent); // okay
}

Playground-länk till kod