1

I'm currently trying to figure out how to create a typed function that will call the following object based on its key:

const commandOrQuery = {
  CREATE_USER_WITH_PASSWORD: CreateUserCommandHandler,
  GET_USERS: GetUsersQueryHandler,
};

In which commandOrQuery is of the shape: Record<string, Function>

I'd like to create a dispatch function that will:

export const dispatch = ({type, args}) => commandOrQuery[type](args)

Where: type is the key of the commandOrQuery object. args are the arguments of the commandOrquery[type] function. And dispatch returns the type of the object.

Meaning that if I do the following:

const result = await dispatch({
  type: "GET_USERS",
  args : { 
    // tenantId is exclusive to "GET_USERS"
    tenantId: string;
  }
});

// result will have the following shape {name: string, id: string}[] which 
// is exclusive to "GET_USERS" and GetUsersQueryHandler

I've come very close by defining the types separately, like this:

export type Mediator =
  | {
      type: "CREATE_USER_WITH_PASSWORD";
      arg: CreateUserCommand | typeof CreateUserCommandHandler;
    }
  | {
      type: "GET_USERS";
      arg: GetUsersQuery | typeof GetUsersQueryHandler;
    }
  | {
      type: "GET_USER_PROFILE";
      arg: GetUserProfile | typeof GetUserProfileQueryHandler;
    };

And defining the dispatch like this:

export const dispatch = ({type, args}: Mediator) => commandOrQuery[type](args)

But I'm currently lacking the return type. I'd like TypeScript to infer the ReturnType automatically after I provide the type in the argument.

Is this even possible?

I've been researching for a couple of hours now: TypeScript conditional return value type? https://stackoverflow.com/search?q=key+function+argument+return+typescript Typescript Key-Value relation preserving Object.entries type

Edit x1: Here's the Code Sandbox: https://codesandbox.io/s/prod-http-50m9zk?file=/src/mediator.ts

Edit x2:

Let's say we have the following structure:

enter image description here

//file: bounded_contexts/CreateUserWithPasswordCommand.ts 
export type CreateUserCommand = {
  email: string;
  password: string;
};

export async function CreateUserCommandHandler(cmd: CreateUserCommand) {
  console.log("Creating User");
  return true;
}
// file: bounded_contexts/GetUserProfileQuery.ts
export type GetUserProfileQuery = {
  userId: string;
};

export async function GetUserProfileQueryHandler(query: GetUserProfileQuery) {
  console.log("Searching in db the userId", query.userId);
  return {
    firstName: "Carlos",
    lastName: "Johnson",
    dateOfBirth: new Date()
  };
}
// file: bounded_contexts/GetUsersQuery.ts
export type GetUsersQuery = {};

export async function GetUsersQueryHandler(q: GetUsersQuery) {
  return {
    users: [
      {
        id: "id-1",
        name: "Julian Perez"
      },
      {
        id: "id-2",
        name: "Adam Smith"
      }
    ]
  };
}

// file: index.ts
import { dispatch } from "./mediator";

(async () => {
  // Result should be boolean
  const result = await dispatch({
    type: "CREATE_USER_WITH_PASSWORD",
    arg: {
      email: "theemail@mail.com",
      password: "the password"
    }
  });

  // result2 should be { users: {name: string; id: string }[]
  const result2 = await dispatch({
    type: "GET_USERS",
    arg: {}
  });
  // resul3 should be { firstName: string; lastName: string; dateOfBirth: Date}
  const result3 = await dispatch({
    type: "GET_USER_PROFILE",
    arg: {
      userId: "the user Id"
    }
  });
})();

// file: mediator.ts
import {
  CreateUserCommandHandler,
  CreateUserCommand
} from "./bounded_contexts/CreateUserWithPasswordCommand";
import {
  GetUsersQueryHandler,
  GetUsersQuery
} from "./bounded_contexts/GetUsersQuery";
import {
  GetUserProfileQueryHandler,
  GetUserProfileQuery
} from "./bounded_contexts/GetUserProfileQuery";

const commandsOrQueries = {
  CREATE_USER_WITH_PASSWORD: CreateUserCommandHandler,
  GET_USERS: GetUsersQueryHandler,
  GET_USER_PROFILE: GetUserProfileQueryHandler
};

type Mediator =
  | {
      type: "CREATE_USER_WITH_PASSWORD";
      arg: CreateUserCommand | typeof CreateUserCommandHandler;
    }
  | {
      type: "GET_USERS";
      arg: GetUsersQuery | typeof GetUsersQueryHandler;
    }
  | {
      type: "GET_USER_PROFILE";
      arg: GetUserProfileQuery | typeof GetUserProfileQueryHandler;
    };

export function dispatch({ arg, type }: Mediator) {
  return commandsOrQueries[type](arg as any);
}

Jose A
  • 10,053
  • 11
  • 75
  • 108
  • 2
    Do you have a playground for your code? Frankly, we at Stack Overflow are quite lazy and want to see the askers themselves make the [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example). Hope you understand :) – kelsny Sep 26 '22 at 18:48
  • 1
    It's not *just* laziness. A [mre] makes it so that we're all talking about the same issue. I can't sit in front of the same computer with OP, so I don't know if I am dealing with their actual problem or just being distracted by something else. For example, what's `CreateUserCommandHandler` and `GetUsersQueryHandler`? If I can't paste code, as-is, into a standalone IDE and get right to work on the issue, I have to worry about re-creating the issue in the first place. Which, even if I weren't lazy (which I am, [don't get me started](//youtu.be/GGjcn3Wf5XI)), would be sub-optimal for everyone. – jcalz Sep 26 '22 at 19:03
  • 1
    Awesome! Gimme some mins and I'll post it – Jose A Sep 26 '22 at 19:04
  • @caTS updated my question, I apologize for not including it earlier. – Jose A Sep 26 '22 at 19:15
  • Please put your [mre] as plain text in the question itself. An external link is a good supplement, but it does not suffice by itself. We can't require people go to another site to see your question. And I notice that the code in codesandbox differs from your code here; you should have the same info in both places or you run the risk of confusing people. – jcalz Sep 26 '22 at 19:23
  • @jcalz: Updated it. – Jose A Sep 26 '22 at 19:30
  • 1
    The kind of correlation you're looking for is tricky to represent, as described in [ms/TS#30581](https://github.com/microsoft/TypeScript/issues/30581), but a way of doing it is laid out in [ms/TS#47109](https://github.com/microsoft/TypeScript/pull/47109) That gives [this approach](https://tsplay.dev/mAQY8m). Does that meet your needs? If so I could write up an answer explaining; if not, what am I missing? (Note that it makes little sense for you to have a union like `arg: CreateUserCommand | typeof CreateUserCommandHandler;`; how could you call a function with that as the input?) – jcalz Sep 26 '22 at 19:35
  • @jcalz: OMGGG, that was it! The reason why I had that is because I wanted to have the possibility to have another function that could push to the `commandOrQuery` object additional `{[key: string]: Function}` pairs. – Jose A Sep 26 '22 at 19:39
  • 1
    Well anyways, here's [my two cents](https://tsplay.dev/m3XbEW), using `Extract`. – kelsny Sep 26 '22 at 19:39
  • @caTS Thank you so much for the response!!! It's also working wonderfully well! – Jose A Sep 26 '22 at 19:41
  • So, @JoseA, should I write up an answer using my solution then, ignoring that union thing? – jcalz Sep 26 '22 at 19:44
  • @jcalz: Yes, please! – Jose A Sep 26 '22 at 19:49

1 Answers1

3

The problem with

const dispatch = ({ type, arg }: Mediator) => commandsOrQueries[type](arg);

is that the compiler cannot see the correlation between commandsOrQueries[type] and arg, even though Mediator is a discriminated union type. This is the subject of microsoft/TypeScript#30581.

The approach recommended at microsoft/TypeScript#47109 is to make the function generic in a keylike type parameter, and represent the correlation between input and output in terms of mapped types on this type parameter. It's a bit tricky.

It could look like this. First we should use a dummy variable name to initially assign your commandsOrQueries:

const _commandsOrQueries = {
  CREATE_USER: CreateUserCommandHandler,
  GET_USERS_QUERY: GetUsersQueryHandler,
  GET_USER_PROFILE: GetUserProfileQueryHandler
};

Then we do some type manipulation to represent the operation of this in terms of some mapped types:

type CommandsOrQueries = typeof _commandsOrQueries;
type Commands = keyof CommandsOrQueries; // "CREATE_USER" | "GET_...
type InputMap = { [K in Commands]: Parameters<CommandsOrQueries[K]>[0] };
type OutputMap = { [K in Commands]: ReturnType<CommandsOrQueries[K]> };

const commandsOrQueries: { [K in Commands]: 
  (arg: InputMap[K]) => OutputMap[K] } = _commandsOrQueries;

So, commandsOrQueries has the same value as _commandsOrQueries, and it's also of the same type, but now the compiler sees that type as a generic operation on keys K in Commands.

Now we can define the Mediator type as a distributive object type as coined in ms/TS#47109:

type Mediator<K extends Commands = Commands> = 
  { [P in K]: { type: P, arg: InputMap[P] } }[K]

So Mediator<K> is generic, and if you specify the type argument you get just the union member you care about. Like Mediator<"CREATE_USER"> is {type: "CREATE_USER", arg: CreateUserCommand}. But if you just write Mediator, then K defaults to Commands and you get the original full union:

type M = Mediator
/* type M = {
    type: "CREATE_USER";
    arg: CreateUserCommand;
} | {
    type: "GET_USERS_QUERY";
    arg: GetUsersQuery;
} | {
    type: "GET_USER_PROFILE";
    arg: GetUserProfileQuery;
} */

And now we can define and implement dispatch:

const dispatch = <K extends Commands>({ type, arg }: Mediator<K>) =>
  commandsOrQueries[type](arg) // okay
// const dispatch: <K extends Commands>({ type, arg }: Mediator<K>) => OutputMap[K]

This compiles without error; the compiler can see that commandsOrQueries[type] is a function which accepts an InputMap[K] and productes an OutputMap[K], and since arg is an InputMap[K], the compiler is happy.

Let's test using it:

async function test() {

  const result = await dispatch({
    type: "CREATE_USER",
    arg: {
      email: "theemail@mail.com",
      password: "the password"
    }
  });
  // Creating User
  result // boolean

  // result2 should be { users: {name: string; id: string }[]
  const result2 = await dispatch({
    type: "GET_USERS_QUERY",
    arg: {}
  });
  console.log(result2.users.map(x => x.name.toUpperCase()).join("; "));
  // "JULIAN PEREZ; ADAM SMITH" 

}
test();

Looks good. The compiler has the correct types for the results as well.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Jesus Christ. I'm having the feeling of being a child and staring at a badass performing sorcery. I didn't have this feeling in a very long time. I appreciate your taking the time to thoroughly answer the question. I'll check out the links and study them! – Jose A Sep 26 '22 at 20:03