reducerify
v1.4.0
Published
Type-safe reducers and tagged unions for TypeScript - simplify state management with automatic action creators and pattern matching
Maintainers
Readme
Reducerify
Reducerify is a lightweight TypeScript library that simplifies the creation of reducers with full type inference and provides advanced state modeling with tagged unions. It helps you build type-safe reducers, automatically generates action creators, and enables pattern matching for discriminated unions.
Features
Reducer Creation
- Fully typed reducers: Get complete TypeScript type inference for your state and actions
- Automatic action creators: Generated action creators with proper payload typing
- Immutable by design: Encourages functional and declarative programming patterns
- Support for actions with and without payload: Flexible action handling
- Immer integration: Optional support for mutable-style updates with Immer
Tagged Unions (New!)
- Type-safe discriminated unions: Create tagged enums with full TypeScript inference
- Pattern matching: Exhaustive and partial pattern matching with
matchAllandmatchSome - Runtime validation: Built-in Zod schema validation
- Type guards: Factory for creating type-safe narrowing functions
- Zero boilerplate: Automatic constructor generation
Installation
# Using pnpm
pnpm install reducerify
# Using bun
bun add reducerify
# Using npm
npm install reducerify
# Using yarn
yarn add reducerifyOptional Dependencies
Immer (optional): For mutable-style reducer updates
pnpm install immerJotai (optional): For Jotai atom integration
pnpm install jotaiZod (optional): Required for tagged unions with runtime validation
pnpm install zod
Table of Contents
Reducers
Pure Functional Way
1. Define your state type
type TodoState = {
todos: Todo[];
newTodo: Todo
};
type Todo = {
name: string;
isClosed: boolean
};2. Create a reducer with handlers
import { forState, type ActionWithPayload } from "reducerify";
const { reducer, actions } = forState<TodoState>().createReducer({
// Action with payload
updateName: (state, action: ActionWithPayload<{ name: string }>) => {
return {
...state,
newTodo: {
...state.newTodo,
name: action.payload.name,
},
};
},
// Action without payload
save: (state) => {
return {
todos: [...state.todos, state.newTodo],
newTodo: { name: '', isClosed: false },
};
},
// Another action with payload
close: (state, action: ActionWithPayload<{ todoIndex: number }>) => {
return {
...state,
todos: state.todos.map((todo, index) =>
index === action.payload.todoIndex
? { ...todo, isClosed: true }
: todo
),
};
},
});3. Use the reducer and actions
// Initial state
let state: TodoState = {
todos: [],
newTodo: { name: '', isClosed: false }
};
// Dispatch actions
state = reducer(state, actions.updateName({ name: 'Learn Reducerify' }));
state = reducer(state, actions.save());
state = reducer(state, actions.close({ todoIndex: 0 }));Immer Way
The Immer integration allows you to write mutable-style updates that are automatically converted to immutable operations.
import { forState, type ActionWithPayload } from "reducerify/reducer/immer";
const { reducer, actions } = forState<TodoState>().createImmerReducer({
updateName: (state, action: ActionWithPayload<{ name: string }>) => {
state.newTodo.name = action.payload.name;
},
save: (state) => {
state.todos.push(state.newTodo);
state.newTodo = { name: '', isClosed: false };
},
close: (state, action: ActionWithPayload<{ todoIndex: number }>) => {
state.todos[action.payload.todoIndex].isClosed = true;
},
});Jotai Integration
Reducerify provides seamless integration with Jotai through the atomWithReducerify utility. This allows you to use reducerify's type-safe reducers within Jotai's atomic state management system.
1. Create a reducer
import { forState, type ActionWithPayload } from "reducerify";
type CounterState = {
count: number;
};
const { reducer, actions } = forState<CounterState>().createReducer({
increment: (state) => ({
...state,
count: state.count + 1,
}),
decrement: (state) => ({
...state,
count: state.count - 1,
}),
add: (state, action: ActionWithPayload<{ value: number }>) => ({
...state,
count: state.count + action.payload.value,
}),
});2. Create a Jotai atom from the reducer
import { atomWithReducerify } from "reducerify/jotai/atomWithReducerify";
const counterAtom = atomWithReducerify(
{ count: 0 }, // Initial state
reducer,
'counterAtom' // Optional debug label for Jotai DevTools
);3. Use the atom in your React components
import { useAtom } from "jotai";
function Counter() {
const [state, dispatch] = useAtom(counterAtom);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch(actions.increment())}>+1</button>
<button onClick={() => dispatch(actions.decrement())}>-1</button>
<button onClick={() => dispatch(actions.add({ value: 10 }))}>+10</button>
</div>
);
}Why use atomWithReducerify?
- Type safety: Full TypeScript inference for state and actions
- Action creators: Use reducerify's automatically generated action creators
- Jotai benefits: Leverage Jotai's atomic state management and React 18+ features
- DevTools support: Optional debug labels for better debugging experience
- Composability: Combine with other Jotai atoms and utilities
Initializing atoms with component props
When you need to initialize an atom with values from component props, pass a function to atomWithReducerify for lazy initialization (similar to React's useState):
import { useMemo } from 'react';
import { useAtom } from 'jotai';
import { atomWithReducerify } from "reducerify/jotai/atomWithReducerify";
type CounterState = {
count: number;
label: string;
};
const { reducer, actions } = forState<CounterState>().createReducer({
increment: (state) => ({ ...state, count: state.count + 1 }),
updateLabel: (state, action: ActionWithPayload<{ label: string }>) => ({
...state,
label: action.payload.label,
}),
});
function Counter({ initialCount, label }: { initialCount: number; label: string }) {
// Create the atom only once with useMemo
// The initializer captures the prop values in its closure
const counterAtom = useMemo(
() => atomWithReducerify(
() => ({ count: initialCount, label }), // Lazy initializer (function)
reducer,
'counterAtom'
),
[] // Empty dependencies - atom created only on first render
);
const [state, dispatch] = useAtom(counterAtom);
return (
<div>
<p>{state.label}: {state.count}</p>
<button onClick={() => dispatch(actions.increment())}>+1</button>
</div>
);
}Key points:
- Pass a function for lazy initialization (like React
useState) - Pass a direct value for eager initialization
- Use
useMemowith empty dependencies to create the atom only once - The initializer function captures prop values in its closure
- The initializer is called lazily (only when the atom is first read or written)
- No intermediate objects are created on rerenders
Note: Each component instance will have its own atom. If you need to share state between components, define the atom outside the component with eager initialization (direct value) instead.
Tagged Unions
Tagged unions (also known as discriminated unions or sum types) provide a type-safe way to model data that can be one of several variants. Reducerify's taggedEnum function creates fully typed discriminated unions with built-in pattern matching and validation.
Basic Usage
1. Define a tagged enum
import { taggedEnum } from "reducerify";
import { z } from "zod";
const RemoteData = taggedEnum({
Loading: {},
Success: { data: z.number() },
Failure: { reason: z.string() }
});2. Create instances
const loading = RemoteData.Loading();
const success = RemoteData.Success({ data: 42 });
const failure = RemoteData.Failure({ reason: "Network error" });3. Extract types
type RemoteDataTypes = typeof RemoteData.Types;
type LoadingType = RemoteDataTypes["Loading"];
// { _tag: "Loading" }
type SuccessType = RemoteDataTypes["Success"];
// { _tag: "Success", data: number }
type FailureType = RemoteDataTypes["Failure"];
// { _tag: "Failure", reason: string }
type RemoteDataAll = RemoteDataTypes["All"];
// LoadingType | SuccessType | FailureTypePattern Matching
Exhaustive matching with matchAll
Handle all possible cases with compile-time exhaustiveness checking:
function handleRemoteData(data: RemoteDataTypes["All"]): string {
return RemoteData.matchAll(data, {
Loading: () => "Loading...",
Success: ({ data }) => `Got: ${data}`,
Failure: ({ reason }) => `Error: ${reason}`
});
}
console.log(handleRemoteData(loading)); // "Loading..."
console.log(handleRemoteData(success)); // "Got: 42"
console.log(handleRemoteData(failure)); // "Error: Network error"Partial matching with matchSome
Handle only specific cases with an optional default handler:
// With _default handler
const result1 = RemoteData.matchSome(success, {
Success: ({ data }) => `Got: ${data}`,
_default: () => "Other case"
});
// Without _default handler - returns undefined for unhandled cases
const result2 = RemoteData.matchSome(success, {
Success: ({ data }) => `Got: ${data}`
}); // "Got: 42"
const result3 = RemoteData.matchSome(loading, {
Success: ({ data }) => `Got: ${data}`
}); // undefinedType Guards
Use the is() factory to create type guards for safe type narrowing:
const value: RemoteDataTypes["All"] = getRemoteData();
if (RemoteData.is("Success")(value)) {
// TypeScript knows value is SuccessType
console.log(value.data); // Type-safe access
}Runtime Validation
The generated Zod schema enables runtime validation of external data:
// Validate external data
const external = { _tag: "Success", data: 42 };
const validated = RemoteData.schema.parse(external);
// Safe parsing without throwing
const result = RemoteData.schema.safeParse(external);
if (result.success) {
console.log(result.data);
}Real-World Example: API Response
import { taggedEnum } from "reducerify";
import { z } from "zod";
const ApiResponse = taggedEnum({
Idle: {},
Loading: {},
Success: {
data: z.object({
id: z.number(),
username: z.string(),
email: z.string().email()
}),
fetchedAt: z.date()
},
Error: {
code: z.enum(["NETWORK_ERROR", "NOT_FOUND", "UNAUTHORIZED"]),
message: z.string()
}
});
type ApiState = typeof ApiResponse.Types.All;
// Usage in a component or function
function renderApiState(state: ApiState): string {
return ApiResponse.matchAll(state, {
Idle: () => "Ready to fetch",
Loading: () => "Fetching data...",
Success: ({ data, fetchedAt }) =>
`User ${data.username} loaded at ${fetchedAt.toISOString()}`,
Error: ({ code, message }) => `[${code}] ${message}`
});
}
// Type-safe filtering
const successStates = states.filter(ApiResponse.is("Success"));
// Handle only errors
const errorMessage = ApiResponse.matchSome(state, {
Error: ({ code, message }) => `${code}: ${message}`
});Advanced Usage
Combining Reducers with Tagged Unions
You can combine reducers with tagged unions to create powerful state management patterns:
import { forState, taggedEnum, type ActionWithPayload } from "reducerify";
import { z } from "zod";
// Define a tagged union for your state
const LoadingState = taggedEnum({
Idle: {},
Loading: {},
Success: { data: z.array(z.string()) },
Error: { message: z.string() }
});
type AppState = {
items: typeof LoadingState.Types.All;
selectedIndex: number | null;
};
// Create a reducer that works with the tagged union
const { reducer, actions } = forState<AppState>().createReducer({
startLoading: (state) => ({
...state,
items: LoadingState.Loading()
}),
loadSuccess: (state, action: ActionWithPayload<{ data: string[] }>) => ({
...state,
items: LoadingState.Success({ data: action.payload.data })
}),
loadError: (state, action: ActionWithPayload<{ message: string }>) => ({
...state,
items: LoadingState.Error({ message: action.payload.message })
}),
selectItem: (state, action: ActionWithPayload<{ index: number }>) => ({
...state,
selectedIndex: action.payload.index
})
});
// Usage
let state: AppState = {
items: LoadingState.Idle(),
selectedIndex: null
};
state = reducer(state, actions.startLoading());
state = reducer(state, actions.loadSuccess({ data: ["item1", "item2"] }));
// Pattern match on the state
const itemsDisplay = LoadingState.matchAll(state.items, {
Idle: () => "Not loaded yet",
Loading: () => "Loading...",
Success: ({ data }) => `Loaded ${data.length} items`,
Error: ({ message }) => `Error: ${message}`
});Type-Safe State Machines
Tagged unions are perfect for implementing type-safe state machines:
const TrafficLight = taggedEnum({
Red: { duration: z.number() },
Yellow: { duration: z.number() },
Green: { duration: z.number() }
});
type TrafficLightState = typeof TrafficLight.Types.All;
function transition(state: TrafficLightState): TrafficLightState {
return TrafficLight.matchAll(state, {
Red: () => TrafficLight.Green({ duration: 30 }),
Yellow: () => TrafficLight.Red({ duration: 45 }),
Green: () => TrafficLight.Yellow({ duration: 5 })
});
}
let light = TrafficLight.Red({ duration: 45 });
light = transition(light); // Green
light = transition(light); // Yellow
light = transition(light); // RedNested Tagged Unions
You can nest tagged unions for complex hierarchical state:
const ServerError = taggedEnum({
NetworkError: { url: z.string() },
TimeoutError: { duration: z.number() },
ValidationError: { fields: z.array(z.string()) }
});
// Untyped error (flexible but less safe)
const ApiResult = taggedEnum({
Pending: {},
Success: { data: z.any() },
Failure: { error: z.any() } // Can contain ServerError instances
});
// Usage
const networkError = ServerError.NetworkError({ url: "/api/users" });
const result = ApiResult.Failure({ error: networkError });
// Nested pattern matching
const message = ApiResult.matchAll(result, {
Pending: () => "Waiting...",
Success: ({ data }) => `Got data: ${JSON.stringify(data)}`,
Failure: ({ error }) => {
if (error._tag) {
return ServerError.matchAll(error, {
NetworkError: ({ url }) => `Network error at ${url}`,
TimeoutError: ({ duration }) => `Timeout after ${duration}ms`,
ValidationError: ({ fields }) => `Validation error: ${fields.join(", ")}`
});
}
return "Unknown error";
}
});Strongly Typed Nested Unions
For full type safety, use the schema from the nested tagged enum:
const ServerError = taggedEnum({
NetworkError: { url: z.string() },
TimeoutError: { duration: z.number() },
ValidationError: { fields: z.array(z.string()) }
});
// Typed error using ServerError schema
const ApiResult = taggedEnum({
Pending: {},
Success: { data: z.any() },
Failure: { error: ServerError.schema } // Fully typed!
});
type ApiResultType = typeof ApiResult.Types.All;
type FailureType = typeof ApiResult.Types.Failure;
// { _tag: "Failure", error: { _tag: "NetworkError", url: string } | { _tag: "TimeoutError", duration: number } | { _tag: "ValidationError", fields: string[] } }
// Usage with full type inference
const networkError = ServerError.NetworkError({ url: "/api/users" });
const result = ApiResult.Failure({ error: networkError });
// Pattern matching with type-safe error handling
const message = ApiResult.matchAll(result, {
Pending: () => "Waiting...",
Success: ({ data }) => `Got data: ${JSON.stringify(data)}`,
Failure: ({ error }) => {
// error is fully typed as ServerError union - no type guard needed!
return ServerError.matchAll(error, {
NetworkError: ({ url }) => `Network error at ${url}`,
TimeoutError: ({ duration }) => `Timeout after ${duration}ms`,
ValidationError: ({ fields }) => `Validation error: ${fields.join(", ")}`
});
}
});Project Structure
The library is organized into two main modules:
src/
├── reducer/
│ ├── pure.ts # Pure functional reducer creation
│ ├── immer.ts # Immer-based reducer creation
│ ├── types.ts # Type definitions for reducers
│ ├── pure.test.ts # Tests for pure reducers
│ └── immer.test.ts # Tests for Immer reducers
├── state/
│ ├── tagged-types.ts # Tagged union implementation
│ └── tagged-types.test.ts # Tests for tagged unions
└── index.ts # Main entry pointModule Exports
Main export (
reducerify):- Reducer utilities:
forState,ActionWithPayload,ActionWithoutPayload, etc. - Tagged unions:
taggedEnum, type utilities, etc.
- Reducer utilities:
Immer export (
reducerify/reducer/immer):- Immer-specific:
forStatewithcreateImmerReducer
- Immer-specific:
Jotai export (
reducerify/jotai/atomWithReducerify):- Jotai integration:
atomWithReducerify,ActionsToActionType
- Jotai integration:
API Reference
Reducer API
forState<TState>()
Creates a reducer factory for a specific state type.
Returns: An object with createReducer method.
createReducer(handlers)
Creates a reducer and action creators from handler functions.
Parameters:
handlers: Map of action types to handler functions
Returns:
reducer: The reducer function that handles state updatesactions: Automatically generated action creators
createImmerReducer(handlers)
Creates a reducer using Immer for mutable-style updates (available from reducerify/reducer/immer).
Parameters:
handlers: Map of action types to handler functions (can mutate state directly)
Returns:
reducer: The reducer function that handles state updatesactions: Automatically generated action creators
atomWithReducerify(initialState, reducer, debugLabel?)
Creates a Jotai atom from a reducerify reducer (available from reducerify/jotai/atomWithReducerify).
Parameters:
initialState: The initial state valuereducer: A reducer function created withcreateReducerdebugLabel(optional): Debug label for Jotai DevTools
Returns: A writable Jotai atom that can be used with useAtom
Types
ActionWithoutPayload: Action without additional dataActionWithPayload<TPayload>: Action with typed payload dataReducerHandlers<TState>: Map of action types to handler functionsActionsToActionType<Actions>: Type helper to extract the union action type from action creators
Tagged Union API
taggedEnum<TDefinition>(definition)
Creates a tagged enum (discriminated union) with constructors and pattern matching.
Parameters:
definition: Object mapping tag names to their field definitions- Empty object
{}for tags without payload - Object of Zod schemas for tags with fields
- Direct Zod schema for single-field tags
- Empty object
Returns: An object containing:
- Constructor functions for each tag (same name as the tag)
schema: Zod discriminated union schema for validationmatchAll: Exhaustive pattern matching functionmatchSome: Partial pattern matching functionis: Type guard factoryTypes: Type utility for extracting variant types
matchAll<TReturnType>(value, cases)
Performs exhaustive pattern matching on a tagged enum value.
Parameters:
value: The tagged enum instance to matchcases: Object with handlers for all possible tags
Returns: The result of the matching handler
matchSome<TReturnType>(value, cases)
Performs partial pattern matching on a tagged enum value.
Parameters:
value: The tagged enum instance to matchcases: Object with handlers for specific tags, plus optional_default
Returns: The result of the matching handler, or undefined if no handler matches
is<TTag>(tag)
Creates a type guard for a specific tag.
Parameters:
tag: The tag name to check for
Returns: A type guard function (value) => boolean
Types
TaggedEnumDefinition: Type for the definition objectInferTaggedEnum<TDefinition>: Infers the union type from a definitionInferTaggedTypes<TSchema>: Infers individual variant types from a schemaConstructors<TDefinition>: Type for the constructor functionsMatchAllCases<TDefinition, TReturnType>: Type for exhaustive match casesMatchSomeCases<TDefinition, TReturnType>: Type for partial match cases
Benchmarks
The taggedEnum implementation is designed to be as fast as hand-written code while providing type safety, pattern matching, and runtime validation out of the box.
Results Summary
| Operation | taggedEnum | manual | 🏆 Winner | Slower by | | ------------------------- | ----------- | ----------- | ---------- | ----------------- | | Object Creation (empty) | 34.6M ops/s | 34.0M ops/s | taggedEnum | manual +1.9% | | Object Creation (payload) | 16.3M ops/s | 15.4M ops/s | taggedEnum | manual +5.6% | | matchAll (empty) | 29.1M ops/s | 34.8M ops/s | manual | taggedEnum +16.3% | | matchAll (payload) | 26.9M ops/s | 34.0M ops/s | manual | taggedEnum +20.8% | | matchSome (matched) | 27.2M ops/s | 31.9M ops/s | manual | taggedEnum +14.6% | | matchSome (default) | 32.5M ops/s | 32.9M ops/s | manual | taggedEnum +1.1% | | is() (true) | 35.9M ops/s | 35.6M ops/s | taggedEnum | manual +0.8% | | is() (false) | 36.1M ops/s | 36.3M ops/s | manual | taggedEnum +0.7% | | safeParse (valid) | 17.9M ops/s | 16.2M ops/s | taggedEnum | manual +9.3% | | safeParse (invalid) | 0.6M ops/s | 0.6M ops/s | taggedEnum | manual +0.3% |
Score final: 🤝 Égalité (5-5)
====================================================================== 🖥️ ENVIRONNEMENT
📅 Date: 2026-01-24 🥟 Bun: 1.3.2 💻 Platform: darwin arm64
Conclusions
- Object creation:
taggedEnumis as fast or faster than manual code - Pattern matching: Manual switch statements are slightly faster for
matchAll, buttaggedEnumprovides exhaustiveness checking at compile time - Type guards: Identical performance
- Validation:
taggedEnumgenerates more optimized Zod schemas
Overall, taggedEnum provides comparable performance to hand-written code while eliminating boilerplate and ensuring type safety.
Run Benchmarks Locally
bun run src/state/tagged-types.bench.tsLicense
MIT © Frédéric Mascaro
See LICENSE for more information.
Build and Publish
Building the package
To compile the package and generate distribution files:
bun run buildThis command performs:
- Clean the
distdirectory - TypeScript compilation with
tsdown - Export generation with
xportify
Pre-publish checks
Before publishing a new version, run the complete verification suite:
bun run ciThis command executes:
- Full build
- Code linting with Biome
- All tests
Publishing to npm
Update the version in
package.json:# Patch version (1.4.0 -> 1.4.1) npm version patch # Minor version (1.4.0 -> 1.5.0) npm version minor # Major version (1.4.0 -> 2.0.0) npm version majorPublish the package:
npm publish
Note: The prepare script in package.json automatically runs bun run build before publishing, ensuring distribution files are always up to date.
