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:
/**
* @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" });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:
/** @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"'.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 asreadonly Item[]if you want tsc to refuse it;Readonly<CartState>alone stops reassigning the properties, notpush. - Name the payload types when actions grow. One
@typedefper 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 }widenstypetostring; pass the object literal directly, or type the variable asCartAction.
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
- Narrowing (discriminated unions) · TypeScript handbook
- Spread syntax (object literals) · MDN Web Docs
Last reviewed 21 October 2026.