typescripttypescript-genericstypescript-types

How can I keep the connection between related types in a generic function?


I have a list of actions, actions, each with one type and one set function.

My goal is to have a FormAction which consists of a type and a value, where the value is guaranteed by TypeScript to have the same type as the first parameter in the corresponding set function. This FormAction should then run it's value on the set function. I'm trying to do this in the generic function runAction.

I can get this to work when the type is fixed, i.e. outside the generic function, but I fails in the generic function. TypeScript looses the connection between the setter and the value although they are generic over the same ActionType. How can I make TypeScript aware of this connection?

Side question: To have getSetter to work I have to do a type assertion on the return value. Ideally I should not need this. Is there a way to write getSetter without the type assertion?

type CForm = {
  config: Config;
};
type Config = {
  projectName: string | undefined;
};

// one action per field
const actions = [
  {
    type: "init",
    set: (value: CForm, _form: CForm) => value,
  },
  {
    type: "setProjectName",
    set: (value: string, form: CForm): CForm => ({
      ...form,
      config: {...form.config, projectName: value}
    }),
  },
] as const;

/** one of the entries in actions */
type SingleAction = (typeof actions)[number];
/** all available action types */
type ActionType = SingleAction["type"];
/** a specific action object from actions array */
type ActionItem<ActionT extends ActionType> = Extract<
  SingleAction,
  { type: ActionT }
>;
/** the setter function corresponding to the action type */
type ActionSetter<ActionT extends ActionType> = ActionItem<ActionT>["set"];

type FormAction<ActionT extends ActionType = ActionType> =
  ActionT extends unknown
    ? {
        type: ActionT;
        value: Parameters<ActionSetter<ActionT>>[0];
      }
    : never;

function getSetter<ActionT extends ActionType>(
  actionType: ActionT,
): ActionSetter<ActionT> {
  const actionItem = actions.find(
    (action): action is ActionItem<ActionT> => action.type === actionType,
  );
  if (!actionItem) {
    throw new Error("unknown action");
  }
  return actionItem.set as ActionSetter<ActionT>;
}

// with a fixed type it works
const formAction: FormAction<"setProjectName"> = {
  type: "setProjectName",
  value: "name",
}
const setter = getSetter(formAction.type);
setter(formAction.value, form);

// in the generic function there is an error
function runAction<FAction extends FormAction>(
  formAction: FAction,
  form: CForm,
) {
  const setter = getSetter(formAction.type);
  // typescript has lost the connection between
  // the setter and the value and complains here
  setter(formAction.value, form);
}
Clarification

All items in actions is expected to fulfill the interface ValidAction below.

interface ValidAction {
  type: string;
  set: (value: any, form: CForm) => CForm;
}

In reality there are many more actions.


Solution

  • TypeScript cannot follow arbitrary correlations this way. When it loses track of a generic correlation it tends to widen the generic to its constraint, giving multiple union-typed values which it also cannot see as correlated. The lack of direct support for correlated unions is described at microsoft/TypeScript#30581.

    The only supported approach I know of is described in detail in microsoft/TypeScript#47109 and involves refactoring all operations to be in terms of: a "base" object type, mapped types over that base type, and generic indexes into these types. It doesn't work directly with conditional types like Extract. (You can use conditional types or anything you want to create the base type, but the subsequent manipulation needs to be mapped types and generic indexes.)

    For your example the base object type looks like this:

    type ActionParam = {
        init: CForm;
        setProjectName: string;
    }
    

    although you can compute it from SingleAction if you want:

    type ActionParam =
      { [T in SingleAction as T["type"]]: Parameters<T["set"]>[0] }
    

    Then your ActionItem, ActionSetter, and FormAction types can be written in the supported way:

    type ActionItem<K extends keyof ActionParam = keyof ActionParam> =
      { [P in K]: { type: P, set: ActionSetter<P> } }[K];
    
    type ActionSetter<K extends keyof ActionParam> =
      { [P in K]: (arg: ActionParam[P], form: CForm) => CForm }[K];
    
    type FormAction<K extends keyof ActionParam = keyof ActionParam> =
      { [P in K]: { type: P, value: ActionParam[P] } }[K]
    

    Here you can verify that if you plug a specific key, either "init" or "setProjectName" as K, that the above types all become the single types you expect. And if you leave them as a union, the above types become the relevant unions; generic indexing into a mapped type as shown in all three of these types is known as making a distributive object type, since it distributes across unions in K.

    Now we can write getSetter():

    function getSetter<K extends keyof ActionParam>(actionType: K): ActionSetter<K> {
      const _actions: readonly ActionItem[] = actions;
      const actionItem = _actions.find(
        (action): action is typeof action & ActionItem<K> => action.type === actionType,
      );
      if (!actionItem) {
        throw new Error("unknown action");
      }
      return actionItem.set;
    }
    

    This is similar to your version, except that I annotated an _actions variable as type readonly ActionItem[] and assign actions to it, and then subsequent things are in terms of _actions. Even though those types are basically the same, microsoft/TypeScript#47109 only works in general when you write things explicitly in terms of the distributive object type ActionItem.

    Oh, also TypeScript stopped recognizing that ActionItem<K> is a subtype of ActionItem, so I used an intersection in the type predicate to assuage its worries. Note that actionItem is of type ActionItem<K>, which has a set property of type ActionSetter<P>, so you can return that value with no type assertion.

    You can choose whether you want to narrow _actions.find() to ActionItem & ActionItem<K> or actions.find() to SingleAction & ActionItem<K>. Either one will work. I'm choosing _actions so the types are as suggested in microsoft/TypeScript#47109, but it (apparently) doesn't matter.

    Finally, runAction:

    function runAction<K extends keyof ActionParam>(
      formAction: FormAction<K>,
      form: CForm,
    ) {
      const setter = getSetter(formAction.type);
      setter(formAction.value, form);
    }
    

    This compiles because setter is of type ActionSetter<K>, which is a single function type (arg: ActionParam[K], form: CForm) => CForm. It's not a union of functions, so you don't have the problem calling it that you originally had. And the first argument is of type ActionParam[K], which is the type of FormAction<K>["value"]. Thus it all compiles as desired.

    Playground link to code