Context and reducers are where types pay off most: both hide the connection between producer and consumer. A reducer's actions are a feature's internal API, and a discriminated union turns that API into something the compiler enforces at every dispatch.
import { createContext, useContext, useReducer } from 'react';
import type { Dispatch, ReactNode } from 'react';
interface State { count: number; step: number }
type Action = { type: 'inc' } | { type: 'setStep'; step: number } | { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'inc': return { ...state, count: state.count + state.step };
case 'setStep': return { ...state, step: action.step };
default:
const unhandled: never = action; // exhaustiveness check
throw new Error(`Unhandled ${JSON.stringify(unhandled)}`);
}
}
const CounterContext = createContext<readonly [State, Dispatch<Action>] | null>(null);
export function CounterProvider({ children }: { children: ReactNode }) {
const api = useReducer(reducer, { count: 0, step: 1 });
return <CounterContext value={api}>{children}</CounterContext>;
}
export function useCounter() {
const api = useContext(CounterContext);
if (!api) throw new Error('useCounter needs <CounterProvider>');
return api; // narrowed to the tuple, never null
}src/ctx.tsx(10,13): error TS2322: Type '{ type: "reset"; }' is not assignable to type 'never'.That one error is the exhaustiveness check working. Inside default, TypeScript has narrowed action past every handled case; when the union is fully covered, what remains is never and the assignment is legal. Add a member to Action and forget to handle it — as 'reset' is here — and it fails, naming what you missed. The union protects callers too: dispatch({ type: 'setStep' }) is rejected with TS2345.
The context needs the | null. createContext demands a default value and there is rarely an honest one, so passing null and narrowing it in a custom access Hook is the standard pattern: useCounter throws outside the provider and is non-nullable everywhere else, so no consumer writes api?.[0]. Two React 19 7,897 conventions appear here — <CounterContext value={...}> renders the context object directly, .Provider no longer being required, and useReducer infers State and Action from the reducer. For larger stores, Redux Toolkit 3,824 's createSlice generates the action union (State Management with Redux).