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

@lokalise/errors

v2.2.0

Published

Readme

@lokalise/errors

Strongly-typed, nominal error classes for TypeScript applications.

The problem this solves

TypeScript's structural type system treats two error classes with identical shapes as assignable to each other, so returning the wrong error goes undetected at compile time:

// Without nominal typing, TS does NOT catch this
const getProjectError = (): ProjectNotFoundError => {
  return new ProjectConflictError('foo') // wrong error, no TS error!
}

This package puts a string literal code on every error class. TypeScript narrows on literal types, so cross-error assignments become compile errors:

const getProjectError = (): ProjectNotFoundError => {
  // TS error: Type '"PROJECT_CONFLICT"' is not assignable to type '"PROJECT_NOT_FOUND"'
  return new ProjectConflictError('foo')
}

The guarantee only holds while code stays a literal type. A hand-written subclass would have to declare code as override readonly itself, and omitting readonly widens the literal to string without a compile error, silently turning the check off. This is why both base classes keep their constructors private: the from() factories are the only way to create concrete classes, and they preserve the literal automatically.

Installation

npm install @lokalise/errors zod

zod is a peer dependency, used to type the details of public errors.

Error hierarchy

EnhancedError (not exported)   shared base: code, details, cause, cross-realm isInstance
├── InternalError              non-public operational errors; create via InternalError.from()
└── PublicError                client-facing errors with type + httpStatusCode; create via PublicError.from()

InternalError

For runtime errors that should never reach clients (timeouts, lock failures, unexpected states). Create classes with the InternalError.from() factory:

import { InternalError } from '@lokalise/errors'

// Without details
class TranslatorTimeoutError extends InternalError.from('TRANSLATOR_TIMEOUT') {
  constructor(translatorId: string) {
    super({ message: `Translator ${translatorId} timed out` })
  }
}

// With typed details, passed as a type argument
class DatabaseQueryError extends InternalError.from('DATABASE_QUERY_ERROR')<{ query: string }> {
  constructor(query: string, cause?: unknown) {
    super({ message: 'Database query failed', details: { query }, cause })
  }
}

The factory result can also be used directly when no custom constructor is needed:

const TranslatorTimeoutError = InternalError.from('TRANSLATOR_TIMEOUT')
throw new TranslatorTimeoutError({ message: 'Translator t-1 timed out' })

For one-off errors whose code is only read generically (e.g. in logging), InternalError.create() builds an instance without declaring a class:

throw InternalError.create({
  code: 'LQA_REVIEW_MISSING',
  message: 'LQA produced no review for the segment',
})

code keeps its literal type and details is typed from the provided value. Prefer a dedicated class when handlers or tests need to match the error (e.g. expect(x).toThrow(CustomError)). Created instances can only be checked with InternalError.isInstance plus a code comparison.

PublicError

For errors that may be surfaced to clients. Creation is two-step: definePublicError() builds a reusable definition (code, error category, and an optional Zod schema for details), and PublicError.from() binds it to a class. Internal errors skip the define step because a code string is their whole definition. Public errors carry more, and the definition object is reused by the contract tooling described below.

import { z } from 'zod'
import { PublicError, ErrorType, definePublicError } from '@lokalise/errors'

// 1. Define the error (reusable for OpenAPI contracts too)
const projectNotFoundErrorDefinition = definePublicError({
  code: 'PROJECT_NOT_FOUND',
  type: ErrorType.NOT_FOUND,
  detailsSchema: z.object({ id: z.string() }),
})

// 2. Create the error class
class ProjectNotFoundError extends PublicError.from(projectNotFoundErrorDefinition) {
  constructor(id: string) {
    super({ message: `Project ${id} not found`, details: { id } })
  }
}

// 3. Use it
const error = new ProjectNotFoundError('abc')
error.code // 'PROJECT_NOT_FOUND'
error.type // 'not-found'
error.httpStatusCode // 404
error.details // { id: string }

Without a details schema, details is undefined and the constructor does not accept a details field:

const rateLimitErrorDefinition = definePublicError({
  code: 'RATE_LIMIT_EXCEEDED',
  type: ErrorType.RATE_LIMIT,
})

class RateLimitError extends PublicError.from(rateLimitErrorDefinition) {
  constructor() {
    super({ message: 'Too many requests' })
  }
}

Serializing to a client-facing payload

toPayload() returns the part of the error that is safe to send to clients: message, code, and details when the definition declares a detailsSchema. Non-public fields (stack, cause, name) are never included.

new ProjectNotFoundError('abc').toPayload()
// { message: 'Project abc not found', code: 'PROJECT_NOT_FOUND', errorCode: 'PROJECT_NOT_FOUND', details: { id: 'abc' } }

new RateLimitError().toPayload()
// { message: 'Too many requests', code: 'RATE_LIMIT_EXCEEDED', errorCode: 'RATE_LIMIT_EXCEEDED' }
// note: no details key

The return type (PublicErrorPayload) keeps code as a literal and details typed. The payload always satisfies the definition's companion schema, so servers can respond with it directly and clients can parse responses with the schema:

reply.status(error.httpStatusCode).send(error.toPayload())

The deprecated errorCode alias

To ease migrating consumers of @lokalise/node-core errors, every error also exposes a deprecated errorCode getter that mirrors code. It appears in toPayload() output. The companion schema includes it as a required field marked deprecated in its metadata (.meta({ deprecated: true })), so generated OpenAPI output flags it accordingly.

Do not use errorCode in new code; read code instead. The alias will be removed in a future major version once the node-core migration is complete. Note it is a getter, so it does not appear in JSON.stringify(error) or own-property log serializers. Log pipelines keyed on errorCode should move to code.

Response schemas by status code (API contracts)

mergeErrorSchemasByStatusCode() groups public error definitions by the HTTP status code derived from their type and returns { [statusCode]: payloadSchema }, ready to be spread into an API contract's responsesByStatusCode (@lokalise/api-contracts):

import { mergeErrorSchemasByStatusCode } from '@lokalise/errors'
import { defineApiContract } from '@lokalise/api-contracts'

const getProjectContract = defineApiContract({
  method: 'get',
  summary: 'Get project',
  visibility: 'public',
  requestPathParamsSchema: z.object({ id: z.string() }),
  pathResolver: ({ id }) => `/projects/${id}`,
  responsesByStatusCode: {
    200: projectSchema,
    ...mergeErrorSchemasByStatusCode([
      projectNotFoundErrorDefinition, // not-found  → 404
      projectNameAlreadyExistsErrorDefinition, // conflict → 409
      projectLockedErrorDefinition, // conflict → 409
    ]),
    // → { 404: <PROJECT_NOT_FOUND schema>,
    //     409: z.discriminatedUnion('code', [<PROJECT_NAME_ALREADY_EXISTS>, <PROJECT_LOCKED>]) }
  },
})

Behavior:

  • A status code claimed by a single definition maps to that definition's schema as-is.
  • A status code shared by several definitions maps to a z.discriminatedUnion('code', ...) of their schemas, so error codes must be unique within a status code. A duplicate code throws at merge time, i.e. when the contract is defined, not on the first parse.
  • Both the status code keys and the payload types are preserved at the type level. z.infer of a mapped schema yields the payload union with literal code and typed details, letting contract consumers discriminate error responses by code. Accessing a status code no definition maps to is a compile error.

[!IMPORTANT] Always pass the definitions as an inline array literal, as in the example above. The type-level guarantees only hold when TypeScript sees exactly which definitions you passed. With a non-literal array (e.g. definitions collected dynamically into a PublicErrorDefinitionWithSchema[]), the type system cannot know which status codes are actually present at runtime: every status code key becomes optional and payload types degrade.

Combined with toPayload(), the server response provably matches the contract. The payload an error serializes to is exactly what the mapped schema validates.

Protocol mapping

PublicError instances expose httpStatusCode as a getter. For cases where you only have an ErrorType value, use the exported httpStatusByErrorType map.

import { httpStatusByErrorType } from '@lokalise/errors'

// On an instance
reply.status(error.httpStatusCode).send(error.toPayload())

// From an ErrorType value
reply.status(httpStatusByErrorType[someType]).send(payload)

| ErrorType | HTTP | | ---------------------- | ---- | | bad-request | 400 | | unauthenticated | 401 | | permission-denied | 403 | | not-found | 404 | | conflict | 409 | | unprocessable-entity | 422 | | rate-limit | 429 | | internal | 500 | | unimplemented | 501 | | upstream-error | 502 | | unavailable | 503 |

For other protocols (gRPC, message queues) create a similar mapping using the ErrorType values.

Checking errors with isInstance

Every error class has a static isInstance() type guard, the preferred way to check errors from this package:

try {
  // ...
} catch (err) {
  if (PublicError.isInstance(err)) {
    return reply.status(err.httpStatusCode).send(err.toPayload())
  }
  if (InternalError.isInstance(err)) {
    logger.error({ code: err.code, details: err.details })
  }
  throw err
}

Every subclass inherits isInstance, and the guard narrows to the class it is called on. Custom error classes get their own fully-typed guard with no extra code. Using the DatabaseQueryError class defined earlier:

if (DatabaseQueryError.isInstance(err)) {
  err.details.query // narrowed to DatabaseQueryError, typed as string
}

Do not use the instanceof operator. It currently runs the same check at runtime, because the package overrides Symbol.hasInstance (see How it works), but consider that behavior deprecated: the override may be removed in a future release, at which point instanceof silently reverts to prototype-chain checks that fail across realms and package copies. isInstance is the supported API.

Override isInstance in a subclass only if you need custom matching logic; the default inherited guard covers the standard cases.

How it works

Cross-realm identity via Symbol.for

On instantiation each error tags itself with Symbol.for symbols derived from its inheritance path. The from() factories name the bound class after the error code (InternalError<CODE> / PublicError<CODE>), and that name is a segment of the path. The TranslatorTimeoutError class from the example above stamps '@lokalise/errors.EnhancedError.InternalError.InternalError<TRANSLATOR_TIMEOUT>.TranslatorTimeoutError'. isInstance checks for those symbols instead of walking the prototype chain. Because Symbol.for symbols are shared globally, isInstance keeps working across realms and across duplicated copies of this package in node_modules, situations where prototype-based checks silently fail.

Class naming rules

The symbols are derived from class names and inheritance structure; they are the cross-realm identity. This has a few consequences:

  • Renaming is a breaking change. Renaming an error class (or moving it in the hierarchy) changes its identity: errors stamped by an older copy of the code stop matching isInstance checks in a newer copy, and vice versa. What looks like a pure refactor (NotFoundError → ResourceNotFoundError) changes runtime behavior across these boundaries.

  • Changing an error code is a rename. The from() factories bake the code into the bound class name, so the code string is part of the identity path of the bound class and every subclass under it. Changing a code breaks isInstance across realms and package copies exactly like renaming the class.

  • Same name + same path = same class. Two classes with the same name and inheritance path match each other's isInstance. That is deliberate, since it is what makes duplicated package copies interoperable, but it also means unrelated classes that happen to share a name and path are indistinguishable. Keep concrete error class names unique across your codebase.

  • Names must survive to runtime. Minifiers that mangle class names (terser without keep_classnames, esbuild --minify without --keep-names) mangle each bundle independently. That silently breaks isInstance across realms and package copies, and can even make unrelated classes collide on the same mangled name. A single consistently-minified bundle keeps working.

  • Every class in the chain must have a name. An anonymous class has nothing stable to derive an identity from, so constructing an error with an unnamed class in its hierarchy throws. Class expressions returned from factories are unnamed; name them explicitly:

    Object.defineProperty(TheClass, 'name', { value: 'TheClass' })

    Avoid . in such custom names. It is the path delimiter, so a class named 'Foo.Bar' produces the same identity as a Foo → Bar inheritance chain.