typescriptgenerics

Difference between argument type and generic in TypeScript


I am trying to build an event-processing system for different models using TypeScript and have faced a super-weird error which looks like a bug of TypeScript.

Let's say we have a model ApprovalStep which is actually a union:

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

We want to have specific handlers for every field of any arbitrary model. Here are the definition of event type and of a listeners set:

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

This looks pretty straightforward, right? So we can just take any model (like ApprovalSteps) and build a map of listeners, like so:

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

But when we're trying to call a specific handler, we're getting super-strange error:

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

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

The error says:

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)

Which is more strange is that when we manually use ApprovalStep for the Listeners type (instead of generic Model), it works!

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

How this can make any difference? Model should 100% equal to ApprovalStep in this case!

The whole example can be found at typescript playground


Solution

  • It's not a bug in TypeScript but I don't blame you for being confused. Here's what's going on. When you have:

    More explicitly, you have type SomeMapping<T> = {[K in keyof T]: ...} and evaluate SomeMapping<A | B | C>, you will get the same result as SomeMapping<A> | SomeMapping<B> | SomeMapping<C>.

    I'm not sure if there is some official documentation of this behavior, but you can read microsoft/TypeScript#26063, which mentions this and other things that happen with homomorphic mapped types. (Read the part that starts: "Given a homomorphic mapped type")

    In general this behavior yields reasonable results, but sometimes, like now for example, it can do seemingly strange things:


    Your definition of Listeners,

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

    is a homomorphic mapped type (Partial<T> itself is homomorphic over T, and if F<T> and G<T> are both homomorphic over T then so is F<G<T>>.) And when you evaluate Listeners<ApprovalStep> you get something like:

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

    meaning that listeners is itself a union type, and so listeners.state, if it exists, is a union of function types of different arguments, and therefore can only be called with the intersection of its arguments (due to the support introduced in TypeScript 3.3 for calling unions of functions). And so you get that strange error about how you're not calling it with the intersection. This is not what you wanted.


    The solution here is probably to prevent the mapping from being homomorphic. This can happen in multiple ways, usually involving some amount of indirection so that the compiler does not interpret the mapping as [K in keyof T] for a type variable T. You want to get something in between the in and the keyof there.

    I'd recommend that you use the Record<K, T> utility type defined something like type Record<K extends PropertyKey, T> = {[P in K]: T}. This type is not homomorphic because K is not keyof anything directly. Even if you call Record<keyof T, ...> it does not become homomorphic.

    So I'd write:

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

    Now when you define listeners,

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

    its type is

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

    which is essentially the same as

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

    It's a single object type, not a union, and the state method takes an argument depending on the full ApprovalStep union type, not the split-apart-and-unioned-together function from before. Now it should work as you expect:

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

    Playground link to code