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

@jfdevelops/create-error

v0.2.1

Published

Create strongly typed, configurable error families.

Readme

@jfdevelops/create-error

Create strongly typed, configurable error families from any synchronous Standard Schema object.

Install

pnpm add @jfdevelops/create-error

Bring your own Standard Schema-compatible validation library, such as Zod, Valibot, or ArkType. create-error does not bundle one.

Quick start

import { createError } from '@jfdevelops/create-error';
import { z } from 'zod';

const createFormError = createError({
  definition: z.object({
    code: z.string(),
    scope: z.string(),
  }),
  data: {
    property: 'context',
    resolve({ definition, input }) {
      return { ...input, scope: definition.scope };
    },
  },
  message({ definition, data, implementation }) {
    return implementation({
      context: data,
      scope: definition.scope,
    });
  },
  properties({ definition, data, implementation }) {
    return {
      code: definition.code,
      scope: definition.scope,
      context: data,
      renderMessage(renderer = implementation) {
        return renderer({
          context: data,
          scope: definition.scope,
        });
      },
    };
  },
  toJSON(error) {
    return {
      name: error.name,
      code: error.code,
      scope: error.scope,
      message: error.message,
      context: error.context,
    };
  },
});

export const FormError = createFormError.Error;

export class InvalidFieldError extends createFormError({
  code: 'invalidField',
  scope: 'field',
})
  .defineContext(
    z.object({
      field: z.string(),
      scope: z.literal('field'),
    }),
  )
  .implement(
    ({ code, context }) => `${code}: ${context.field} is invalid`,
  ) {}

const error = new InvalidFieldError({ field: 'email' });

error.code; // "invalidField"
error.scope; // "field"
error.context; // { field: string; scope: "field" }
error instanceof Error; // true
error instanceof FormError; // true

No explicit generic arguments or as const assertions are needed. Definition inputs are checked by the schema, and definition-derived fields use its parsed output type so transformations remain type-safe.

For reusable or separately declared configurations, the package exports a common CreateErrorConfig type:

import { createError, type CreateErrorConfig } from '@jfdevelops/create-error';

const definition = z.object({ code: z.string() });

const config = {
  definition,
  data: {
    property: 'context',
    resolve: ({ input }) => input,
  },
  message: ({ data, implementation }) => implementation(data),
} satisfies CreateErrorConfig<typeof definition>;

const createDomainError = createError(config);

How the factory works

The API has four stages:

  1. createError(blueprint) configures a family and returns a factory.
  2. factory(definition) validates and captures one definition.
  3. The returned builder optionally selects a context schema with defineContext(schema).
  4. implement(callback) captures the implementation and produces an extendable concrete error class.

The definition builder exposes both defineContext and implement. defineContext returns a narrower builder that exposes only implement, so a context schema can be selected only once. Calling implement directly keeps context schemas optional. Every implementation receives exactly one argument; the blueprint decides its shape when it calls implementation(argument). When that argument is a constructed object, the concrete definition fields are added automatically, so implementations can read values such as code without the blueprint repeating them. Forwarding opaque data directly preserves its original scalar, tuple, object, or class-instance shape.

The definition option must implement Standard Schema V1 and must produce an object. Validation runs when factory(definition) is called. Because class creation is synchronous, a schema whose validate method returns a promise or other thenable is rejected with a TypeError. createError also checks that data.resolve, message, and any properties or toJSON options are functions.

What data and properties do

data defines the constructor-input lifecycle. Its resolve callback receives the concrete definition and the value passed to new ErrorClass(input). The callback can preserve that input, normalize it, or combine it with definition values. After optional defineContext validation, the final value is stored on the instance using data.property.

properties defines the rest of the instance API. It can expose definition values such as a stable error code, pass the resolved data through, or add methods and getters. It does not participate in input validation or transformation. Because each concrete error types its data separately, the blueprint can store data but cannot read its fields; derive values from definition or call implementation(data) instead.

For a small error family, the constructor input can pass straight through and properties can be omitted entirely:

const createLookupError = createError({
  definition: z.object({ code: z.string() }),
  data: {
    property: 'resourceId',
    resolve: ({ input }) => input,
  },
  message: ({ data, implementation }) => implementation(data),
});

class MissingUserError extends createLookupError({ code: 'missingUser' })
  .defineContext(z.string())
  .implement((resourceId) => `User ${resourceId} was not found`) {}

const error = new MissingUserError('user_123');
error.resourceId; // string

Use both options when constructor input needs normalization and the error should present a richer public API:

const createValidationError = createError({
  definition: z.object({
    code: z.string(),
    section: z.string(),
  }),
  data: {
    property: 'context',
    resolve: ({ definition, input }) => ({
      ...input,
      section: definition.section,
    }),
  },
  message: ({ data, implementation }) => implementation(data),
  properties: ({ definition, data }) => ({
    code: definition.code,
    section: definition.section,
    describe: () => `${definition.code} in ${definition.section}`,
  }),
});

class InvalidEmailError extends createValidationError({
  code: 'invalidEmail',
  section: 'profile',
})
  .defineContext(
    z.object({
      field: z.string(),
      section: z.literal('profile'),
    }),
  )
  .implement(({ field }) => `${field} is invalid`) {}

const error = new InvalidEmailError({ field: 'email' });
error.context; // { field: string; section: "profile" }
error.code; // "invalidEmail"
error.describe(); // "invalidEmail in profile"

Use schema-native restrictions when definitions have a closed set of values:

const createHttpError = createError({
  definition: z.object({
    code: z.enum(['notFound', 'unauthorized']),
    scope: z.literal('request'),
  }),
  data: {
    property: 'details',
    resolve: ({ input }) => input,
  },
  message: ({ implementation, data }) => implementation(data),
});

const NotFoundError = createHttpError({
  code: 'notFound',
  scope: 'request',
})
  .defineContext(z.object({ resource: z.string() }))
  .implement((details) => `${details.resource} was not found`, {
    name: 'NotFoundError',
  });

Naming error classes

A class declared with class NotFoundError extends ... {} uses its own name for error.name, stack traces, and serialized output. A class assigned to a variable has no name of its own, so pass name as the second argument to implement. Without either, instances keep the native "Error" name.

const NotFoundError = createHttpError({ code: 'notFound', scope: 'request' })
  .implement((details: { resource: string }) => details.resource, {
    name: 'NotFoundError',
  });

new NotFoundError({ resource: 'user' }).name; // "NotFoundError"

Data and behavior

data.resolve can return any value: objects, scalars, tuples, unions, class instances, null, and undefined are all supported. When defineContext is used, its Standard Schema validates the resolved data, its input type determines the concrete error constructor input, and its output type is passed to the implementation and stored on the error. Without a context schema, the last parameter of the consumer implementation determines those types.

For each new error instance, callbacks run in this order:

  1. data.resolve
  2. Context validation and transformation, when configured
  3. message
  4. properties, when configured

Exceptions thrown by a schema or callback are not wrapped. Native Error behavior is preserved, including stack traces, subclass names, prototypes, and the optional cause passed as the second constructor argument.

Only plain objects passed to implementation(argument) receive the definition fields. Class instances, such as a Date or Map, are passed through unchanged. The types recognize common built-in classes, but they cannot tell your own class instances from plain objects, so wrap those in an object, as in implementation({ user }), when the implementation needs definition fields.

Every family exposes its shared base as factory.Error, and factory.is(value) checks whether a value came from any class in the family. Both narrow to the fields every family error shares: definition-derived properties use the schema's output type, and the data property is unknown.

try {
  await saveForm();
} catch (error) {
  if (createFormError.is(error)) {
    error.code; // string
    error.context; // unknown
  }
}

is is an instanceof check, so errors created by another copy of the factory, such as one loaded in a different realm, do not match. To name the shared instance type, use InstanceType<typeof createFormError.Error>.

Concrete classes and named subclasses also inherit a lazy invariant helper:

InvalidFieldError.invariant(
  formIsValid,
  () => ({ field: 'email' }),
  { cause },
);

The input function runs only when the condition is falsy. TypeScript only allows assertion methods on explicitly typed names, so the static method works on class declarations. For a class stored in a const, use the standalone invariant export, which narrows the same way:

import { invariant } from '@jfdevelops/create-error';

invariant(NotFoundError, user, () => ({ resource: 'user' }));

When an error's constructor input is itself a function, pass it wrapped, as in () => handler, so it is not mistaken for lazy input. The types enforce this.

When toJSON is configured, it is installed once on the family prototype and used by JSON.stringify. Its callback can read native error fields, configured properties, and the resolved data property; each concrete error's toJSON() return type preserves parsed definition output and context-schema output. When omitted, the family does not add a toJSON method.

Property safety

properties may add fields, methods, getters, symbols, and other own property descriptors. It cannot replace name, message, stack, cause, constructor, or prototype. The configured data property may only be repeated with the exact resolved value. Collisions throw a TypeError.

The library always creates the family base class. Customize a family through the blueprint instead of supplying a separate BaseError.

Releasing

This project uses Changesets for versioning and changelogs. Add a changeset with pnpm changeset whenever a package change should be released. Maintainers can consume pending changesets with pnpm version-packages, review the updated version and changelog, then publish with pnpm release.

License

MIT