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

@oskorokhod/usecase

v1.0.2

Published

Reusable Zod-validated service-layer building blocks: ServiceBase pipeline, ServiceError, and Validator.

Downloads

212

Readme

@oskorokhod/usecase

Reusable, Zod-validated service-layer building blocks: a ServiceBase execution pipeline, a structured ServiceError, and a thin Validator wrapper.

@oskorokhod/usecase extracts the service/validation core into a framework-agnostic package. It has no dependency on Fastify or any HTTP layer — drop it into any Node project that uses Zod for validation.

What it is

@oskorokhod/usecase is a tiny toolkit for writing service-layer objects (a.k.a. interactors / command objects) with Zod-validated input and a single, predictable execution pipeline. It ships three building blocks:

  • ServiceBase<TValidParams, TServiceResult> — an abstract class that runs every call through a fixed sequence (run → validate → checkPermissions → aroundExecute → execute → onSuccess | onError). You implement execute() (business logic) and checkPermissions() (authorization); everything else is provided with sensible defaults.
  • ServiceError<T> — a real instanceof Error carrying a structured, field-level error map and a code (default 'VALIDATION_ERROR'), with .toObject() for JSON responses. Zod errors are auto-converted to a path → issue-code map.
  • Validator<TInput> / IValidator<TInput> — a thin wrapper over a ZodType; validate() returns parsed data or false, getErrors() returns the last ZodError.

When to use it

Reach for @oskorokhod/usecase when you want to:

  • Keep business logic out of controllers/route handlers and in small, testable units.
  • Validate-then-act with a consistent contract: validation failures always surface as a ServiceError with structured fields, never as ad-hoc thrown values.
  • Standardize cross-cutting concerns (transactions, retries, logging, timing) through one seam — aroundExecute() — instead of scattering them across call sites.
  • Get end-to-end TypeScript types inferred from your Zod schema, while still being safe to call from plain JavaScript (runtime guards check that the required hooks exist).

It is not an HTTP framework, ORM, or DI container — it is the thin service/validation core you drop into whatever stack you already use.

How to use it

npm install @oskorokhod/usecase zod

zod is a peer dependency (^4) — see Why zod is a peer dependency. You install it yourself so the package shares your single copy.

Ships ESM + CommonJS + type declarations. Importable from both:

import { ServiceBase, ServiceError, Validator } from "@oskorokhod/usecase"; // ESM
const { ServiceBase, ServiceError, Validator } = require("@oskorokhod/usecase"); // CJS

Quick start

Use @oskorokhod/usecase in two steps:

  1. Create one app-specific base class that extends ServiceBase. This is where you centralize cross-cutting concerns shared by every service — transactions/retries via aroundExecute, error logging via onError, a default checkPermissions policy, and so on. You write this once.
  2. Create each service by extending that base class and implementing the business logic (execute), overriding only the hooks that service needs.

Step 1 — your app base class (write once)

import { ServiceBase } from "@oskorokhod/usecase";

// Keep the generics open so each concrete service supplies its own types.
abstract class AppService<TValidParams = unknown, TServiceResult = unknown> extends ServiceBase<
	TValidParams,
	TServiceResult
> {
	// Shared cross-cutting concerns for every service in your app.
	protected async aroundExecute(
		cleanData: TValidParams,
		proceed: (data: TValidParams) => Promise<TServiceResult>,
	): Promise<TServiceResult> {
		// e.g. wrap in a transaction, timing, retries...
		return proceed(cleanData);
	}

	// Default permission policy; override per service to restrict.
	protected async checkPermissions(): Promise<boolean> {
		return true; // throw a ServiceError to deny
	}

	protected async onError(error: unknown): Promise<void> {
		console.error("service failed", error);
	}
}

Step 2 — a concrete service

import { z } from "zod";
import { ServiceError } from "@oskorokhod/usecase";

class CreateUser extends AppService<{ email: string }, { userId: string }> {
	static validation = z.object({ email: z.email() });

	protected async execute(data: { email: string }): Promise<{ userId: string }> {
		return { userId: crypto.randomUUID() };
	}
}

const result = await new CreateUser().run({ email: "[email protected]" });

try {
	await new CreateUser().run({ email: "not-an-email" });
} catch (err) {
	if (err instanceof ServiceError) {
		console.log(err.code); // 'VALIDATION_ERROR'
		console.log(err.fields); // { email: 'invalid_format' }
	}
}

The pipeline

run(inputData) drives every service through a fixed sequence:

run() → validate() → checkPermissions() → aroundExecute() → execute() → onSuccess | onError

| Hook | Required? | Purpose | | ----------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | static validation | optional | A Zod schema. If set, input is validated and a Validator is cached per subclass (__validator on the constructor). | | validate(data) | provided | Runs the cached validator; throws ServiceError with fields on failure. Returns data ?? {} when no schema is set. | | checkPermissions(cleanData) | abstract | Implement authorization. Throw ServiceError to deny. | | aroundExecute(cleanData, proceed) | optional | Wrapping seam for cross-cutting concerns (transactions, retries). Override and call proceed(cleanData); call super.aroundExecute(...) to chain. | | execute(cleanData) | abstract | Business logic. | | onSuccess(result, context) | optional | Called after success with a RunContext (timing + raw/clean data). | | onError(error, context) | optional | Called on any thrown error, then the error is re-thrown. |

run() also includes runtime guards (for plain-JS callers) that throw if execute/checkPermissions are missing.

Ad-hoc validation

For dynamic or per-call rules, use validateWithRules — it does not use the per-subclass cache:

const clean = await this.validateWithRules<{ id: number }>(input, z.object({ id: z.number() }));

API

  • ServiceBase<TValidParams, TServiceResult> — abstract base class (the pipeline above).
  • RunContext<TValidParams>{ inputData, cleanData, startTime, endTime, executionTimeMs } passed to lifecycle hooks.
  • ServiceError<T>extends Error. Constructed with { fields, code? } where fields is a ZodError (auto-converted to a path→issue-code map) or a plain record. Exposes .code (default 'VALIDATION_ERROR'), .fields, and .toObject() for JSON serialization.
  • Validator<TInput> / IValidator<TInput> — wraps a ZodType; validate(data) returns the parsed data or false, getErrors() returns the last ZodError (or null), prepare() is a chainable no-op reserved for future setup.

Gotchas

  • Validator.validate() returns false on failure, it does not throw. ServiceBase translates that false into a ServiceError. Preserve this contract if you use Validator directly — callers may branch on the boolean.
  • getErrors() is only meaningful after a failed validate() call (it returns the last result's ZodError, else null).
  • The validator is cached on the subclass constructor as __validator. Don't mutate how rules attach in a way that invalidates this cache. validateWithRules intentionally bypasses it.
  • aroundExecute is the only intended extension seam for transactions/retries — override it, don't wrap run().
  • ServiceError extends Error and uses private fields (#fields, #code); it is a real instanceof Error.

Why zod is a peer dependency

ServiceError detects Zod errors with fields instanceof ZodError, and the ZodError instances it receives are produced by your schemas. If this package bundled its own copy of zod, that instanceof check would compare against a different ZodError class and silently fail — your structured fields would not be extracted.

Declaring zod as a peer dependency (and marking it external in the build) guarantees the package and your app share one zod instance. Always install zod@^4 alongside this package.

Local registry (Verdaccio)

For local development and pre-release testing you can publish to a local Verdaccio registry instead of the public npm one. It runs as a Docker container — the only prerequisite is Docker, no global Verdaccio install needed.

The registry is defined in docker-compose.yml and configured by verdaccio/config.yaml (anonymous install and publish, with everything else proxied/cached from the public npm registry). Package data persists in the verdaccio-storage Docker volume.

# 1. Start the registry (http://localhost:4873). One-time per machine boot.
npm run registry:up

# 2. Run all checks, then publish a commit-pinned dev build to the local registry.
#    Version becomes <version>-build.<git-short-hash> (e.g. 1.0.0-build.77e080f),
#    so every commit is a fresh version — no manual bumping, no republish conflicts.
#    package.json is restored automatically; your working tree is left untouched.
npm run dev:publish

# 3. Consume the dev build from any project:
npm install @oskorokhod/usecase@dev zod@^4 --registry http://localhost:4873/

# Stop the registry when done (data persists; add `-v` to docker compose down to wipe).
npm run registry:down

The published dev build carries the dev dist-tag. Install it as @oskorokhod/usecase@dev, or pin the exact @oskorokhod/[email protected].<hash>. Production releases go to the public npm registry separately (npm publish).

Scripts

| Script | Purpose | | ----------------------- | -------------------------------------------------------------------------------------------------------- | | npm run build | Bundle ESM + CJS + .d.ts/.d.cts via tsup | | npm run typecheck | tsc --noEmit | | npm test | Run the vitest suite | | npm run check:exports | Validate the types/exports matrix with @arethetypeswrong/cli | | npm run check:publish | Lint the publish surface with publint | | npm run check | Run the full pre-publish gate (typecheck → lint → format → test → build → exports → publish) | | npm run registry:up | Start the local Verdaccio registry (Docker) on port 4873 | | npm run registry:down | Stop the local Verdaccio registry | | npm run dev:publish | Run check, then publish a commit-pinned dev build to the local registry | | npm run release | Run check, then publish a production release to the public npm registry (uses NPM_TOKEN from .env) |

Acknowledgements

@oskorokhod/usecase is inspired by chista — a minimal, framework-agnostic base class for building clean service layers. @oskorokhod/usecase follows the same service/use-case philosophy, swapping chista's LIVR validation for Zod and end-to-end TypeScript inference.

License

ISC.