Libraries

Migrating

Convert a package with dozens of bare throws to an evlog catalog in five ordered steps, without a breaking release and without a big-bang rewrite.

You have a published package, thirty throw new Error(...) sites, and consumers matching on message strings because that is all you gave them. Replacing everything in one release breaks those consumers and ships a catalog whose why and fix were written in an afternoon. The order below converts the package in five steps, each one shippable, with the boundary you already have as the first target. The example package is mylib; @github-tools/sdk followed the same path and its catalog on Structured Errors is the result.

Step 1: Inventory the throws and group them by cause

List every throw site and every place an upstream error escapes uncaught. Group by what went wrong, not by where it went wrong: ten call sites that all fail because the token is missing are one catalog entry.

Terminal
grep -rn "throw new Error\|throw err\|reject(" src/ | wc -l

The groups become your first entry list. Most packages land between eight and fifteen entries; a group you cannot name a fix for is not an entry yet, and stays a bare error until you can. Errors Agents Can Act On is the test each group has to pass.

Step 2: Pick the prefix and add the peer dependency

The prefix is your package name in the form a host will type in a dashboard filter: github_tools, not @github-tools/sdk. It never changes after the first release that ships a code, so decide it once.

package.json
{
  "peerDependencies": {
    "evlog": "^2.0.0"
  },
  "exports": {
    ".": "./dist/index.mjs",
    "./errors": "./dist/errors.mjs"
  }
}

Adding a peer dependency is a minor release for most packages: hosts that already run evlog change nothing, and hosts that do not get an install prompt. Say so in the changelog.

Step 3: Write the catalog before touching a throw

Create the catalog module with every entry from step 1, and write why, fix, and link for each one now, while the causes are in front of you. Ship this step on its own: the module exists, nothing throws from it yet, and a consumer can already import the codes.

src/errors.ts
import { defineErrorCatalog } from 'evlog'

export const errors = defineErrorCatalog('mylib', {
  TOKEN_REQUIRED: {
    status: 401,
    message: 'API token is required. Pass it as `token` or set MYLIB_TOKEN.',
    why: 'No token string or MYLIB_TOKEN environment variable was available when the client resolved its credentials.',
    fix: 'Pass `token` when creating the client, or set MYLIB_TOKEN in the environment the process runs in.',
    link: 'https://mylib.dev/guide/auth',
  },
  RATE_LIMITED: {
    status: 429,
    message: ({ resetAt }: { resetAt: string }) => `Rate limit exhausted; resets at ${resetAt}`,
    why: 'The token used up its request quota for this resource.',
    fix: 'Stop calling this method and retry after the reset timestamp in the message.',
    link: 'https://mylib.dev/guide/rate-limits',
  },
})

declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    mylib: typeof errors
  }
}

Step 4: Replace the throws at the boundary first

Start with the one place upstream errors enter your package: the HTTP client, the SDK wrapper, the parser. A mapping function there converts most of the inventory in one file, keeps the original as cause, and leaves unmapped statuses untouched:

src/client.ts
import { errors } from './errors'

function toMylibError(error: unknown): unknown {
  if (!(error instanceof Error) || !('status' in error)) return error
  const status = (error as { status: number }).status
  const overrides = { cause: error, internal: { status } }

  if (status === 401) return errors.TOKEN_REQUIRED(overrides)
  if (status === 429) return errors.RATE_LIMITED({ resetAt: resetHeader(error), ...overrides })
  return error
}

export async function request(path: string): Promise<Response> {
  try {
    return await fetchWithAuth(path)
  } catch (error) {
    throw toMylibError(error)
  }
}

Then work through the remaining sites, one entry at a time. The messages consumers matched on keep working as long as the new message contains the old text, which is the compatibility rule for this step: change the shape, keep the words, and note the codes in the changelog so consumers can move to code at their pace.

Step 5: Export the reference and lock the codes

With every throw converted, generate the error reference from the catalog and add the two tests that make the catalog a contract:

test/errors.test.ts
import { errors } from '../src/errors'

it('keeps the wire format stable', () => {
  expect(errors.TOKEN_REQUIRED.code).toBe('mylib.TOKEN_REQUIRED')
  expect(errors.RATE_LIMITED.code).toBe('mylib.RATE_LIMITED')
})

Exporting the Catalog is the script and the staleness test; Testing covers the event side. From here a new failure mode is one catalog entry, one mapping line, and one regenerated reference, and the host, the support channel, and the agent all learn about it from the same four sentences.