1

A Github issue suggests that we can use TypedAction.defineWithoutPayloadfor this purpose but I couldn't find any relevant examples on how to do so.

I use this for login when an accessToken is being stored in the payload. If the token is present, the user gets access to private pages.

export const login = (token: string) => typedAction("LOGIN", token);

Now, on the logout button, I am trying to implement an action that removes the stored value in the payload. In this case, there will be no parameters for dispatching the action. So how can I write the typedAction?

If I use:

export const logout = () => typedAction("LOGOUT");

I start getting an error on my payload of my reducer that property doesn't exist on type logout.

Here's my reducer:

export const tokenReducer = (
  state: IState['token'] = null,
  { type, payload }: AppAction,
): typeof state => {
  switch (type) {
    case 'LOGIN':
      return payload;
      case 'LOGOUT':
        return null;
    default:
      return state;
  }
};

Codesandbox: https://codesandbox.io/s/keen-brook-kntkm?file=/src/store/actions/login.ts:50-118

Email: c@c.com Password: check

Edit:

export interface IState {
    token: string | null;
  }
const initialState: IState = {
  token: null
};

Error on action.payload if I use state: typeof initialState or state = initialStateas per IDE's suggestion:

Type 'string' is not assignable to type 'IState'.ts(2322)

If I try state: initialStatethen obviously:

'initialState' refers to a value, but is being used as a type here. Did you mean 'typeof initialState'?ts(2749)
``
  • In your code sandbox, it looks like you already have `typedAction` handling no payload actions; is that not working? Are you getting compiler errors doing something like `export const logout = () => typedAction("LOGOUT");`? Since the action is unchanging and parameterless, you could even just have it as a constant instead of an action creator. – Jacob Apr 17 '20 at 15:46
  • Actually, that line works but then I start getting an error in the tokenReducer on payload that ```Property 'payload' does not exist on type '{ type: "LOGOUT"; } | { type: "LOGIN"; payload: string; }'.ts(2339)``` @Jacob –  Apr 17 '20 at 15:51
  • Does changing the single-arity overload to `export function typedAction(type: T): { type: T, payload: void };` work? Fork: https://codesandbox.io/s/vigorous-wescoff-qf4h7?file=/src/store/actions/action-creators.ts – Jacob Apr 17 '20 at 15:59
  • Nope, it still gives the same error, along with other errors like 'expected 1 argument but got 2' @Jacob –  Apr 17 '20 at 16:01
  • If `initialState` is of type `IState`, why not use the type `IState` instead of doing a `typeof`? If you're trying to get the type of the token variable, use `const tokenReducerInitialState: IState['token'] = null`. – Jacob Apr 17 '20 at 18:40

1 Answers1

1

The way you have your typedAction function defined works fine:

export function typedAction<T extends string>(type: T): { type: T };
export function typedAction<T extends string, P extends any>(
  type: T,
  payload: P
): { type: T; payload: P };
export function typedAction(type: string, payload?: any) {
  return { type, payload };
}

The problem you're having is because of the destructuring of the action in your reducer parameters:

export const tokenReducer = (
  state: IState["token"] = null,
  { type, payload }: AppAction
): typeof state => {
  // ...
};

One of the difficulties with destructuring and TypeScript is that once you do that, the typing of the variables become independent from each other. Destructuring the action into { payload, type } makes a type: 'LOGIN' | 'LOGOUT' and payload: string | undefined variable. Even if you later refine the value of type, like in your switch statement, payload still has the type string | undefined; TypeScript will not automatically refine the type of payload in the case block after type is refined; their typings are completely independent.

So a somewhat ugly hack you can use is to not destructure:

export const tokenReducer = (
  state: IState['token'] = null,
  action: AppAction,
): typeof state => {
  switch (action.type) {
    case 'LOGIN':
      return action.payload;
    case 'LOGOUT':
      return null;
    default:
      return state;
  }
};

This works because in your switch statement, it's able to refine the action: AppAction type into the more specific login or logout types, so action.payload now is strongly tied to the payload type specific to one of those actions.

Here's an alternative pattern for redux actions I use that you might find more convenient at my fork that lets you enjoy the power of mapped types to define reducers with less boilerplate. First, you must define a type with the type/payload mappings and define some types deriving from that:

export type ActionPayloads = {
  LOGIN: string;
  LOGOUT: void;
};

export type ActionType = keyof ActionPayloads;

export type Action<T extends ActionType> = {
  type: T;
  payload: ActionPayloads[T];
};

Your action creators can now be defined in terms of that map:

export function typedAction<T extends ActionType>(
  type: T,
  payload: ActionPayloads[T]
) {
  return { type, payload };
}

Next, you can define a helper function for creating a strongly-typed reducer:

type ReducerMethods<State> = {
  [K in ActionType]?: (state: State, payload: ActionPayloads[K]) => State
};

type Reducer<State> = (state: State, action: AppAction) => State;

function reducer<State>(
  initialState: State,
  methods: ReducerMethods<State>
): Reducer<State> {
  return (state: State = initialState, action: AppAction) => {
    const handler: any = methods[action.type];
    return handler ? handler(state, action.payload) : state;
  };
}

(I haven't found a good workaround for that ugly : any cast, but at least we know logically that the typing is sound from the outside).

Now you can define your reducers thusly with nice implicit typing for your action handlers:

type TokenState = string | null;

export const tokenReducer = reducer<TokenState>(null, {
  LOGIN: (state, token) => token, // `token` is implicitly typed as `string`
  LOGOUT: () => null              // TS knows that the payload is `undefined`
});
Jacob
  • 77,566
  • 24
  • 149
  • 228
  • Before your answer, I was planning to add ```const initialState: IState = { token: null };``` in the beginning and change ```state: IState["token"] = null```to ```state: initialState``` but it gave an error. Could you suggest a fix for it? –  Apr 17 '20 at 18:19
  • It depends: how is `IState` defined, and what's the error? – Jacob Apr 17 '20 at 18:26
  • I added it in the qs –  Apr 17 '20 at 18:37