Recipe · Objects and data

How do I type a reducer and its actions in JavaScript?

  • Node.js (ES modules)
  • Reducers
  • Discriminated unions
  • @typedef
  • Checked with TypeScript 5.9

The problem My cart state is updated by a reducer that switches on action.type. How do I type the state and the actions in JSDoc so each case knows its payload, and a dispatch with a wrong action or field is caught?

The short answer

Describe the state with a @typedef, describe the actions as a union of object types that share a type property, and give the reducer both, plus @returns for the new state:

cart.js cart.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @typedef {object} CartState
 * @property {{ sku: string, quantity: number }[]} items
 * @property {string | null} coupon
 */

/**
 * @typedef {{ type: "add", sku: string }
 *   | { type: "remove", sku: string }
 *   | { type: "applyCoupon", code: string }} CartAction
 */

/**
 * @param {CartState} state
 * @param {CartAction} action
 * @returns {CartState}
 */
export function cartReducer(state, action) {
  switch (action.type) {
    case "add": {
      const line = state.items.find((item) => item.sku === action.sku);
      if (line) return { ...state, items: state.items.map((item) => (item === line ? { ...item, quantity: item.quantity + 1 } : item)) };
      return { ...state, items: [...state.items, { sku: action.sku, quantity: 1 }] };
    }
    case "remove":
      return { ...state, items: state.items.filter((item) => item.sku !== action.sku) };
    case "applyCoupon":
      return { ...state, coupon: action.code };
  }
}

/** @type {CartState} */
let state = { items: [], coupon: null };
state = cartReducer(state, { type: "add", sku: "LAMP-01" });
state = cartReducer(state, { type: "applyCoupon", code: "SPRING" });
interface CartState {
  items: { sku: string; quantity: number }[];
  coupon: string | null;
}

type CartAction = { type: "add"; sku: string } | { type: "remove"; sku: string } | { type: "applyCoupon"; code: string };

export function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add": {
      const line = state.items.find((item) => item.sku === action.sku);
      if (line) return { ...state, items: state.items.map((item) => (item === line ? { ...item, quantity: item.quantity + 1 } : item)) };
      return { ...state, items: [...state.items, { sku: action.sku, quantity: 1 }] };
    }
    case "remove":
      return { ...state, items: state.items.filter((item) => item.sku !== action.sku) };
    case "applyCoupon":
      return { ...state, coupon: action.code };
  }
}

let state: CartState = { items: [], coupon: null };
state = cartReducer(state, { type: "add", sku: "LAMP-01" });
state = cartReducer(state, { type: "applyCoupon", code: "SPRING" });
A reducer with a union of actions.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

Inside case "add", action.sku exists; inside case "applyCoupon", action.code does. Every branch must return a whole CartState, and each dispatch is checked against the union.

Why it works

The action union is a discriminated union: type has a different string literal in each member, so a switch on it narrows action to one member per case. The @returns {CartState} tag checks every return, and spreading ...state first keeps the fields a case does not change. A multi-line @typedef is fine: the union can continue on the next lines of the comment, as long as the name comes after the closing brace.

The same thing in TypeScript

The TypeScript equivalent tab declares interface CartState and type CartAction = … and annotates the reducer’s parameters and return type inline. The reducer’s body is the same JavaScript in both files.

What goes wrong

A typo in the returned state is caught with a suggestion (TS2561, a relative of TS2353); a payload field the action does not have is TS2353; and an action type that is not in the union is TS2322:

cart.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: cart.js(15,37): error TS2561: Object literal may only specify known properties, but 'coupn' does not exist in type 'CartState'. Did you mean to write 'coupon'? cart.js(22,35): error TS2353: Object literal may only specify known properties, and 'code' does not exist in type '{ type: "add"; sku: string; }'. cart.js(24,22): error TS2322: Type '"applyCoupon"' is not assignable to type '"add" | "clear"'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. cart.js(15,37): error TS2561: Object literal may only specify known properties, but 'coupn' does not exist in type 'CartState'. Did you mean to write 'coupon'? cart.js(22,35): error TS2353: Object literal may only specify known properties, and 'code' does not exist in type '{ type: "add"; sku: string; }'. cart.js(24,22): error TS2322: Type '"applyCoupon"' is not assignable to type '"add" | "clear"'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. cart.js(15,37): error TS2561: Object literal may only specify known properties, but 'coupn' does not exist in type 'CartState'. Did you mean to write 'coupon'? cart.js(22,35): error TS2353: Object literal may only specify known properties, and 'code' does not exist in type '{ type: "add"; sku: string; }'. cart.js(24,22): error TS2322: Type '"applyCoupon"' is not assignable to type '"add" | "clear"'. checked 21 October 2026
/** @typedef {{ items: string[], coupon: string | null }} CartState */
/** @typedef {{ type: "add", sku: string } | { type: "clear" }} CartAction */

/**
 * @param {CartState} state
 * @param {CartAction} action
 * @returns {CartState}
 */
export function cartReducer(state, action) {
  switch (action.type) {
    case "add":
      return { ...state, items: [...state.items, action.sku] };
    case "clear":
      return { ...state, items: [], coupn: null };error TS2561: Object literal may only specify known properties, but 'coupn' does not exist in type 'CartState'. Did you mean to write 'coupon'?
  }
}

/** @type {CartState} */
const state = { items: [], coupon: null };
cartReducer(state, { type: "add", code: "SPRING" });error TS2353: Object literal may only specify known properties, and 'code' does not exist in type '{ type: "add"; sku: string; }'.
cartReducer(state, { type: "applyCoupon" });error TS2322: Type '"applyCoupon"' is not assignable to type '"add" | "clear"'.
An action with the wrong field, an unknown action, and a state with a typo.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

Gotchas

  • Return new objects. The types do not stop state.items.push(…), which mutates the old state. Type the arrays as readonly Item[] if you want tsc to refuse it; Readonly<CartState> alone stops reassigning the properties, not push.
  • Name the payload types when actions grow. One @typedef per action (AddAction, RemoveAction) and a union of their names reads better than one long braced union.
  • Dispatches built in variables lose the literal. const action = { type: "add", sku } widens type to string; pass the object literal directly, or type the variable as CartAction.

Questions

How do I type an action creator?

Give it the action type as its @returns: /** @param {string} sku @returns {CartAction} */ const addItem = (sku) => ({ type: "add", sku });. Without the tag, the returned type widens to string and the reducer refuses the action.

Should the reducer have a default case?

With a @returns type and a switch that covers every action, tsc is satisfied without one. Add a default with a never check, as in the discriminated unions recipe, if you want a missing case named in the error.

Sources

Last reviewed 21 October 2026.