
Security News
Deno Goes the Way of the Dinosaur
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.
Type-safe reducers and tagged unions for TypeScript - simplify state management with automatic action creators and pattern matching
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.
matchAll and matchSome# Using pnpm
pnpm install reducerify
# Using bun
bun add reducerify
# Using npm
npm install reducerify
# Using yarn
yarn add reducerify
Immer (optional): For mutable-style reducer updates
pnpm install immer
Jotai (optional): For Jotai atom integration
pnpm install jotai
Zod (optional): Required for tagged unions with runtime validation
pnpm install zod
type TodoState = {
todos: Todo[];
newTodo: Todo
};
type Todo = {
name: string;
isClosed: boolean
};
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
),
};
},
});
// 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 }));
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;
},
});
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.
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,
}),
});
import { atomWithReducerify } from "reducerify/jotai/atomWithReducerify";
const counterAtom = atomWithReducerify(
{ count: 0 }, // Initial state
reducer,
'counterAtom' // Optional debug label for Jotai DevTools
);
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>
);
}
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:
useState)useMemo with empty dependencies to create the atom only onceNote: 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 (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.
import { taggedEnum } from "reducerify";
import { z } from "zod";
const RemoteData = taggedEnum({
Loading: {},
Success: { data: z.number() },
Failure: { reason: z.string() }
});
const loading = RemoteData.Loading();
const success = RemoteData.Success({ data: 42 });
const failure = RemoteData.Failure({ reason: "Network error" });
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 | FailureType
matchAllHandle 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"
matchSomeHandle 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}`
}); // undefined
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
}
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);
}
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}`
});
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}`
});
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); // Red
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";
}
});
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(", ")}`
});
}
});
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 point
Main export (reducerify):
forState, ActionWithPayload, ActionWithoutPayload, etc.taggedEnum, type utilities, etc.Immer export (reducerify/reducer/immer):
forState with createImmerReducerJotai export (reducerify/jotai/atomWithReducerify):
atomWithReducerify, ActionsToActionTypeforState<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 functionsReturns:
reducer: The reducer function that handles state updatesactions: Automatically generated action creatorscreateImmerReducer(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 creatorsatomWithReducerify(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 with createReducerdebugLabel (optional): Debug label for Jotai DevToolsReturns: A writable Jotai atom that can be used with useAtom
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 creatorstaggedEnum<TDefinition>(definition)Creates a tagged enum (discriminated union) with constructors and pattern matching.
Parameters:
definition: Object mapping tag names to their field definitions
{} for tags without payloadReturns: An object containing:
schema: Zod discriminated union schema for validationmatchAll: Exhaustive pattern matching functionmatchSome: Partial pattern matching functionis: Type guard factoryTypes: Type utility for extracting variant typesmatchAll<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 tagsReturns: 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 _defaultReturns: 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 forReturns: A type guard function (value) => boolean
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 casesThe 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.
| 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)
📅 Date: 2026-01-24 🥟 Bun: 1.3.2 💻 Platform: darwin arm64
taggedEnum is as fast or faster than manual codematchAll, but taggedEnum provides exhaustiveness checking at compile timetaggedEnum generates more optimized Zod schemasOverall, taggedEnum provides comparable performance to hand-written code while eliminating boilerplate and ensuring type safety.
bun run src/state/tagged-types.bench.ts
MIT © Frédéric Mascaro
See LICENSE for more information.
To compile the package and generate distribution files:
bun run build
This command performs:
dist directorytsdownxportifyBefore publishing a new version, run the complete verification suite:
bun run ci
This command executes:
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 major
Publish 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.
FAQs
Type-safe reducers and tagged unions for TypeScript - simplify state management with automatic action creators and pattern matching
The npm package reducerify receives a total of 0 weekly downloads. As such, reducerify popularity was classified as not popular.
We found that reducerify demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.

Security News
Anthropic’s free OSS Scanner uses Mythos and other Claude models to find open source vulnerabilities and send maintainers reports with proposed fixes.

Security News
On Front Lines’ Early Adopters: CISO podcast, Andrew Becherer shares lessons from Datadog, weighs the long-term costs of AI-built tools, and explains how founders can actually gain a CISO’s trust.