1

I have a type that takes an optional generic. If generic G provided, a new property of type G must be included. However, I'm running into a problem when doing this in a function:

interface Message {
  id: string;
  message: string;
}

interface MessageWithContext<T> extends Message {
  context: T;
};

// Optional generic. If generic provided,
// context must also provided.
type NotificationMessage<T = void> = T extends void
  ? Message
  : MessageWithContext<T>;

interface Context1 {
  status: 'GOOD';
}

interface Context2 {
  status: 'BAD';
  error: string;
}

type AllContext = Context1 | Context2;

// DOES NOT WORK. See error below.
function toMessage(context: AllContext): NotificationMessage<AllContext> {
  return {
    id: '123',
    message: 'hello world',
    context: context
  };
}

Playground link.

The error that I'm getting:

Type '{ id: string; message: string; context: AllContext; }' is not assignable to type 'MessageWithContext<Context1> | MessageWithContext<Context2>'.
  Type '{ id: string; message: string; context: AllContext; }' is not assignable to type 'MessageWithContext<Context2>'.
    Types of property 'context' are incompatible.
      Type 'AllContext' is not assignable to type 'Context2'.
        Property 'error' is missing in type 'Context1' but required in type 'Context2'.(2322)
azizj
  • 3,428
  • 2
  • 25
  • 33
  • Your playground link and code here does not result in the error you are showing. Did you forget an `id` property? It's helpful to have a true [mre] so that people can immediately get to work solving the problem without first needing to re-create it. – jcalz Mar 18 '22 at 14:51
  • 1
    Does [this approach](https://tsplay.dev/Wybabw) meet your needs? Unless you intend `NotificationMessage` to be [distributive over unions](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types) in `T`, then you don't need a distributive conditional type, and things will start working for you when you change it to a regular conditional type. – jcalz Mar 18 '22 at 14:55
  • (Please edit the plaintext code as well, not just the playground link) – jcalz Mar 18 '22 at 14:56
  • Thanks @jcalz! I did fix both the link and the plaintext. And YES that does fix it! Thank you! Why does putting square brackets make it non-distributive? The link provided doesn't go into that. – azizj Mar 18 '22 at 15:02

1 Answers1

2

This definition of NotificationMessage:

type NotificationMessage<T = void> = T extends void
  ? Message
  : MessageWithContext<T>;

is a distributive conditional type, because the type T that you're checking is a generic type parameter. Distributive conditional types distribute the type operation over unions in the relevant type argument, so that NotificationMessage<A | B | C> will be evaluated as NotificationMessage<A> | NotificationMessage<B> | NotificationMessage<C>:

type M = NotificationMessage<AllContext>
// type M = MessageWithContext<Context1> | 
//   MessageWithContext<Context2>

Which means NotificationMessage<AllContext> is itself a union type. And while the value { id: '123', message: 'hello world', context: context } could be considered assignable to such a union type, the compiler doesn't make any attempt to verify this. It only tries to do this if the target type is a discriminated union, and NotificationMessage<AllContext> is not one (it has no discriminant property... no, subproperties don't count, see ms/TS#18758). So there's an error.

If you really wanted NotificationMessage<AllContext> to itself be a union type, we could look at how to fix up toMessage() to allow it to work. But you don't seem to actually be trying to do this. You'd presumably be happy if NotificationMessage<AllContext> were a single object type with a union-typed context property.

That means you don't want NotificationMessage<T> to be distributive in T. The easiest way to "shut off" distributivity is to wrap each side of the T extends U condition in square brackets to make one-element tuples:

type NotificationMessage<T = void> = [T] extends [void]
  ? Message
  : MessageWithContext<T>;

See this Stack Overflow Q/A about avoiding distributivity for more information about why this works. Now you've got a non-distributive type:

type M = NotificationMessage<AllContext>
// type M = MessageWithContext<AllContext>

And now everything just works:

function toMessage(context: AllContext): NotificationMessage<AllContext> {
  return {
    id: '123',
    message: 'hello world',
    context: context
  }; // okay
}

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360