Home > Software engineering >  TypeScript type for a dispatcher in a dictionary
TypeScript type for a dispatcher in a dictionary

Time:09-27

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: 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: "[email protected]",
      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);
}

CodePudding user response:

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: "[email protected]",
      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

  • Related