1

I'm trying to create a helper generic actionCreator function to avoid actionCreator-type functions for each of my actions:

interface Response {
    readonly value: 'A' | 'B';
}

enum ActionTypes {
    created = 'ns/created',
    deleted = 'ns/deleted',
}

interface Created {
    readonly type: ActionTypes.created;
    readonly payload: Response;
}

interface Deleted {
    readonly type: ActionTypes.deleted;
}

type Action = Created | Deleted;

// actionCreator = (type, payload) => ({type, payload});
// actionCreator(ActionTypes.created, response);
// actionCreator(ActionTypes.deleted);

Is it possible to tell TS that actionCreator should not just return a string and an optional payload object but to be aware of type Action to check type and payload pairs? Something like function actionCreator<T>(type: T.type, payload: T.payload) which is not valid TS.

1 Answer 1

3

All of the following assumes you have more than just two types of action. If you really have just two, then use function overloads and be done with it:

function actionCreator(type: ActionTypes.created, payload: Response): Created;
function actionCreator(type: ActionTypes.deleted): Deleted;
function actionCreator(type: any, payload?: any) {
  return (typeof payload === 'undefined') ? { type } : { type, payload }
}

For more general stuff, read on:


Once the conditional types feature lands in TypeScript 2.8 (expected to be released sometime in Mar 2018, you can try it now with typescript@next) you will be able to specify a function that behaves as (I think) you want without touching your existing code, like this:

// If K is a key of T, return T[K].  Otherwise, return R
type MaybePropType<T, K extends string, R=never> = K extends keyof T ? T[K] : R

// Given K, one of the "type" properties of the Action union,
// produce the constituent of Action union whose type is K.  So:
// ActionFromType<ActionTypes.created> is Created, and
// ActionFromType<ActionTypes.deleted> is Deleted.
type ActionFromType<K extends Action['type'], AA = Action>
  = AA extends infer A
  ? MaybePropType<AA, 'type'> extends K ? AA : never
  : never

// The one-argument actionCreator() call accepts an element of ActionTypes
// corresponding to an Action with no payload, and produces an Action of that type.
function actionCreator<
  T extends ActionTypes & ('payload' extends keyof ActionFromType<T> ? never : {})
  >(type: T): ActionFromType<T>;

// The two-argument actionCreator() call accepts an element from ActionTypes
// corresponding to an Action with a payload, and a value of the payload type,
// and produces an Action of that type.
function actionCreator<
  T extends ActionTypes & ('payload' extends keyof ActionFromType<T> ? {} : never)
  >(type: T, payload: MaybePropType<ActionFromType<T>, 'payload'>): ActionFromType<T>;

// The implementation of actionCreator() creates a one-or-two-property
// value from its arguments.  Note that this implementation won't be right
// if you ever add new Action types that require more properties
function actionCreator(type: any, payload?: any) {
  return (typeof payload === 'undefined') ? { type } : { type, payload }
}

Here's how it works:

declare const resp: Response;

// no errors
const created = actionCreator(ActionTypes.created, resp); // Created
const deleted = actionCreator(ActionTypes.deleted); // Deleted

// errors
const badCreated = actionCreator(ActionTypes.created); // missing payload
const badDeleted = actionCreator(ActionTypes.deleted, "payload?!"); // unexpected payload
const badDeleted2 = actionCreator('ns/deleted'); // not typed as an enum

That's a lot of type juggling, and some of the error messages will be cryptic, and it depends on a very new feature.


If you don't mind changing your code around, you can get something that works with TypeScript 2.7 and where actionCreator() is easier to reason about... but the boilerplate before it might be a bit daunting:

// create a mapping from action type to payload type
type ActionPayloads = {
  'ns/created': Response
}
// create a list of actions with no payloads
type ActionsWithoutPayloads = 'ns/deleted';

// this is a helper type whose keys are the ActionTypes
// and whose values represent the part of the Action without the type
// so, just {} for a payloadless type, and {readonly payload: P} for
// a payloaded type
type ActionMap = { [K in ActionsWithoutPayloads]: {} } &
  { [K in keyof ActionPayloads]: { readonly payload: ActionPayloads[K] } }

// this is a helper function which makes sure we define ActionTypes
// correctly
const keysOfActionMap = <T extends { [k: string]: keyof ActionMap }>(x: T) => x;

// now we have ActionTypes.  It is a plain object, not an enum, 
// but it behaves similarly
const ActionTypes = keysOfActionMap({
  created: 'ns/created',
  deleted: 'ns/deleted'
});

// some helper type functions
type IntersectionToObject<T> = { [K in keyof T]: T[K] }
type ValueOf<T> = T[keyof T];

// extract Action from previous stuff.  
// Action by itself is a union of actions from before, while
// Action<K> is the action corresponding to K from ActionTypes.
type Action<K extends keyof ActionMap = keyof ActionMap> =
  ValueOf<{
    [P in K]: IntersectionToObject<{ readonly type: P } & ActionMap[P]>
  }>

// if you need names for Created and Deleted:    
type Created = Action<typeof ActionTypes.created>;
type Deleted = Action<typeof ActionTypes.deleted>;

Finally here's the function, which is typed more simply:

// actions without payloads only accept one parameter
function actionCreator<K extends ActionsWithoutPayloads>(
  type: K): Action<K>;

// actions with payloads require two parameters
function actionCreator<K extends keyof ActionPayloads>(
  type: K, payload: ActionPayloads[K]): Action<K>;

// same impl as before
function actionCreator(type: any, payload?: any) {
  return (typeof payload === 'undefined') ? { type } : { type, payload }
}

And let's make sure it works:

declare const resp: Response;

// no errors
const created = actionCreator(ActionTypes.created, resp); // Created
const deleted = actionCreator(ActionTypes.deleted); // Deleted
const okayDeleted = actionCreator('ns/deleted'); // okay this time

// errors
const badCreated = actionCreator(ActionTypes.created); // missing payload
const badDeleted = actionCreator(ActionTypes.deleted, "payload?!"); // unexpected payload 

Whew! Hope that helps; good luck.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.