dmmulroy

better-result-migrate-v2

@dmmulroy/better-result-migrate-v2
dmmulroy
489
8 forks
Updated 1/18/2026
View on GitHub

Migrate better-result TaggedError from v1 (class-based) to v2 (factory-based) API

Installation

$skills install @dmmulroy/better-result-migrate-v2
Claude Code
Cursor
Copilot
Codex
Antigravity

Details

Pathskills/migrations/v2/SKILL.md
Branchmain
Scoped Name@dmmulroy/better-result-migrate-v2

Usage

After installing, this skill will be available to your AI coding assistant.

Verify installation:

skills list

Skill Instructions


name: better-result-migrate-v2 description: Migrate better-result TaggedError from v1 (class-based) to v2 (factory-based) API

better-result-migrate

Migrate better-result TaggedError classes from v1 (class-based) to v2 (factory-based) API.

When to Use

  • Upgrading better-result from v1 to v2
  • User asks to migrate TaggedError classes
  • User mentions TaggedError v1/v2 migration

V1 API (old)

class FooError extends TaggedError {
  readonly _tag = "FooError" as const;
  constructor(readonly id: string) {
    super(`Foo: ${id}`);
  }
}

// Static methods on TaggedError
TaggedError.match(err, { ... })
TaggedError.matchPartial(err, { ... }, fallback)
TaggedError.isTaggedError(value)

V2 API (new)

class FooError extends TaggedError("FooError")<{
  id: string;
  message: string;
}>() {}

// Standalone functions
matchError(err, { ... })
matchErrorPartial(err, { ... }, fallback)
isTaggedError(value)
TaggedError.is(value)  // also available
FooError.is(value)     // class-specific check

Migration Rules

1. Simple class (no constructor logic)

// BEFORE
class FooError extends TaggedError {
  readonly _tag = "FooError" as const;
  constructor(readonly id: string) {
    super(`Foo: ${id}`);
  }
}

// AFTER
class FooError extends TaggedError("FooError")<{
  id: string;
  message: string;
}>() {}

// Usage changes:
// BEFORE: new FooError("123")
// AFTER:  new FooError({ id: "123", message: "Foo: 123" })

2. Class with computed message

Keep custom constructor to derive message:

// BEFORE
class NotFoundError extends TaggedError {
  readonly _tag = "NotFoundError" as const;
  constructor(readonly resource: string, readonly id: string) {
    super(`${resource} not found: ${id}`);
  }
}

// AFTER
class NotFoundError extends TaggedError("NotFoundError")<{
  resource: string;
  id: string;
  message: string;
}>() {
  constructor(args: { resource: string; id: string }) {
    super({ ...args, message: `${args.resource} not found: ${args.id}` });
  }
}

// Usage: new NotFoundError({ resource: "User", id: "123" })

3. Class with validation

Keep validation in custom constructor:

// BEFORE
class ValidationError extends TaggedError {
  readonly _tag = "ValidationError" as const;
  constructor(readonly field: string) {
    if (!field) throw new Error("field required");
    super(`Invalid: ${field}`);
  }
}

// AFTER
class ValidationError extends TaggedError("ValidationError")<{
  field: string;
  message: string;
}>() {
  constructor(args: { field: string }) {
    if (!args.field) throw new Error("field required");
    super({ ...args, message: `Invalid: ${args.field}` });
  }
}

4. Class with additional runtime properties

// BEFORE
class TimestampedError extends TaggedError {
  readonly _tag = "TimestampedError" as const;
  readonly timestamp = Date.now();
  constructor(readonly reason: string) {
    super(reason);
  }
}

// AFTER
class TimestampedError extends TaggedError("TimestampedError")<{
  reason: string;
  timestamp: number;
  message: string;
}>() {
  constructor(args: { reason: string }) {
    super({ ...args, message: args.reason, timestamp: Date.now() });
  }
}

5. Static method migrations

V1V2
TaggedError.match(err, handlers)matchError(err, handlers)
TaggedError.matchPartial(err, handlers, fallback)matchErrorPartial(err, handlers, fallback)
TaggedError.isTaggedError(x)isTaggedError(x) or TaggedError.is(x)

6. Import updates

// BEFORE
import { TaggedError } from "better-result";

// AFTER
import { TaggedError, matchError, matchErrorPartial, isTaggedError } from "better-result";

Workflow

  1. Find TaggedError classes: Search for extends TaggedError in the codebase
  2. Analyze each class:
    • Extract _tag value
    • Identify constructor params and their types
    • Check for constructor logic (validation, computed message, side effects)
  3. Transform class:
    • Simple: Remove constructor, add props to type parameter
    • Complex: Keep custom constructor, transform to object args
  4. Update usages: Change new FooError(a, b) to new FooError({ a, b, message })
  5. Migrate static methods: TaggedError.matchmatchError, etc.
  6. Update imports: Add matchError, matchErrorPartial, isTaggedError

Example Full Migration

Input:

import { TaggedError } from "better-result";

class NotFoundError extends TaggedError {
  readonly _tag = "NotFoundError" as const;
  constructor(readonly id: string) {
    super(`Not found: ${id}`);
  }
}

class NetworkError extends TaggedError {
  readonly _tag = "NetworkError" as const;
  constructor(readonly url: string, readonly status: number) {
    super(`Request to ${url} failed with ${status}`);
  }
}

type AppError = NotFoundError | NetworkError;

const handleError = (err: AppError) =>
  TaggedError.match(err, {
    NotFoundError: (e) => `Missing: ${e.id}`,
    NetworkError: (e) => `Failed: ${e.url}`,
  });

Output:

import { TaggedError, matchError } from "better-result";

class NotFoundError extends TaggedError("NotFoundError")<{
  id: string;
  message: string;
}>() {
  constructor(args: { id: string }) {
    super({ ...args, message: `Not found: ${args.id}` });
  }
}

class NetworkError extends TaggedError("NetworkError")<{
  url: string;
  status: number;
  message: string;
}>() {
  constructor(args: { url: string; status: number }) {
    super({ ...args, message: `Request to ${args.url} failed with ${args.status}` });
  }
}

type AppError = NotFoundError | NetworkError;

const handleError = (err: AppError) =>
  matchError(err, {
    NotFoundError: (e) => `Missing: ${e.id}`,
    NetworkError: (e) => `Failed: ${e.url}`,
  });