npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

reducerify

v1.4.0

Published

Type-safe reducers and tagged unions for TypeScript - simplify state management with automatic action creators and pattern matching

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 matchAll and matchSome
  • 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 reducerify

Optional Dependencies

  • 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

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 useMemo with 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 | FailureType

Pattern 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}`
}); // undefined

Type 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); // Red

Nested 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 point

Module Exports

  • Main export (reducerify):

    • Reducer utilities: forState, ActionWithPayload, ActionWithoutPayload, etc.
    • Tagged unions: taggedEnum, type utilities, etc.
  • Immer export (reducerify/reducer/immer):

    • Immer-specific: forState with createImmerReducer
  • Jotai export (reducerify/jotai/atomWithReducerify):

    • Jotai integration: atomWithReducerify, ActionsToActionType

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 updates
  • actions: 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 updates
  • actions: 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 value
  • reducer: A reducer function created with createReducer
  • debugLabel (optional): Debug label for Jotai DevTools

Returns: A writable Jotai atom that can be used with useAtom

Types

  • ActionWithoutPayload: Action without additional data
  • ActionWithPayload<TPayload>: Action with typed payload data
  • ReducerHandlers<TState>: Map of action types to handler functions
  • ActionsToActionType<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

Returns: An object containing:

  • Constructor functions for each tag (same name as the tag)
  • schema: Zod discriminated union schema for validation
  • matchAll: Exhaustive pattern matching function
  • matchSome: Partial pattern matching function
  • is: Type guard factory
  • Types: 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 match
  • cases: 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 match
  • cases: 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 object
  • InferTaggedEnum<TDefinition>: Infers the union type from a definition
  • InferTaggedTypes<TSchema>: Infers individual variant types from a schema
  • Constructors<TDefinition>: Type for the constructor functions
  • MatchAllCases<TDefinition, TReturnType>: Type for exhaustive match cases
  • MatchSomeCases<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: taggedEnum is as fast or faster than manual code
  • Pattern matching: Manual switch statements are slightly faster for matchAll, but taggedEnum provides exhaustiveness checking at compile time
  • Type guards: Identical performance
  • Validation: taggedEnum generates 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.ts

License

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 build

This command performs:

  1. Clean the dist directory
  2. TypeScript compilation with tsdown
  3. Export generation with xportify

Pre-publish checks

Before publishing a new version, run the complete verification suite:

bun run ci

This command executes:

  • Full build
  • Code linting with Biome
  • All tests

Publishing to npm

  1. 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
  2. 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.