rickard
rickard

Reputation: 428

In TypeScript is it possible to infer string literal types for Discriminated Unions from input type of string?

Background

Currently creating events in my TypeScript app looks like this:


// This function creates event creators
function defineEvent<E extends {type:string, payload:any}>(type:E["type"]) {
  return (payload:E["payload"]) => ({
    type,
    payload
  })
}

// This function creates foo events
const fooCreator = defineEvent<{
  type:"foo", 
  payload: {
    foo:string
  }
}>("foo");

// This function creates bar events 
const barCreator = defineEvent<{
  type:"bar", 
  payload:{
    bar:string
  }
}>("bar");

// Example events
const fooEvent = fooCreator({foo:'foo'});
const barEvent = barCreator({bar:'bar'});

// Create a union type to switch over
type AppEvent = ReturnType<typeof fooCreator> | ReturnType<typeof barCreator>;

// Example of switching with a discriminated union
function switchOnEvents(event: AppEvent) {
  switch(event.type){
    case "foo": 
      // compiler is happy about payload having a foo property
      return event.payload.foo.toLowerCase();
    case "bar":
      // compiler is happy about payload having a bar property
      return event.payload.bar.toLowerCase();
  } 
}

Question

This works and is okish however this means I need to specify the event type twice when defining event creators which in some ways could be considered redundant.

const fooCreator = defineEvent<{
  type:"foo", // defining type here
  payload: {
    foo:string
  }
}>("foo"); // also here to create the actual string value

Is it possible to create a return type for the creator function that extracts the string literal from the input type argument to the factory function?

Which would mean the example above would still work if the event creators were provided like so:

const barCreator = defineEvent<{
  payload:{
    bar:string
  }
}>("bar");

Failed Attempt

I am guessing I need to merge two types but somehow infer the string literal.

However this doesn't work:

function defineEvent<E extends { payload:any}, ET extends string>(type:ET) {
  type RE = E & {type: ET};
  return (payload:E["payload"]) => {
    return ({
      type,
      payload
    } as any ) as RE
  }
}

The compiler wants me to pass a second type argument.

const fooCreator = defineEvent<{
  payload: {
    foo:string
  } //Expected 2 type arguments, but got 1.
}>("foo");

Anyone know how to do this or if it is impossible?

Upvotes: 0

Views: 518

Answers (1)

jcalz
jcalz

Reputation: 329453

The problem here is that TypeScript does not yet support partial type parameter inference; your defineEvent() function fundamentally depends on two types: the payload type (call it P), and the, uh, "type" string literal type (call it T). You want to specify P but have the compiler infer T, and that's not supported. You can either specify both of them, or the compiler can try to infer both of them.


So there are two possible workarounds I know of. One is to use currying, where a function returns another function. The first function is generic in P, which you will specify, and the returned function is generic in T, which will be inferred from the function's argument. Like this:

const defEvent = <P>() => <K extends string>(type: K) => (payload: P) => ({
  type,
  payload
});

const fooCreator = defEvent<{ foo: string }>()("foo");
const barCreator = defEvent<{ bar: string }>()("bar");

This gives you the same fooCreator and barCreator objects you had before, and you specify exactly the payload type and the type string. The awkwardness here is in the extra chained function call.


The other workaround is to use a dummy payload parameter which allows the compiler to infer both T and P. The dummy parameter is not used by the body of the defineEvent() function; it's only used by the type system. That also means you don't really need to pass an actual value of type P in; you can use a type assertion to pass something like null or undefined:

const defEvent = <P, T extends string>(payloadDummy: P, type: T) => (
  payload: P
) => ({
  type,
  payload
});

const fooCreator = defEvent(null! as { foo: string }, "foo");
const barCreator = defEvent(null! as { bar: string }, "bar");

This is again, the same fooCreator and barCreator you had. The awkwardness here, of course, is using the dummy parameters. I tend to prefer currying over dummying, but that's up to you.


Okay, hope that helps; good luck!

Link to code

Upvotes: 1

Related Questions