Overview
Version 2 of better-result introduces a streamlined TaggedError API that reduces boilerplate and improves type safety. This guide covers breaking changes, migration strategies, and new features.
The v2 migration primarily affects TaggedError class definitions and static methods. Core Result APIs remain unchanged.
Breaking Changes
TaggedError API
The most significant change is the new factory-based TaggedError API:
class NotFoundError extends TaggedError {
readonly _tag = "NotFoundError" as const ;
constructor ( readonly id : string ) {
super ( `Not found: ${ id } ` );
}
}
const err = new NotFoundError ( "123" );
Key differences:
No manual _tag declaration needed
Constructor accepts object with all properties
Factory pattern: TaggedError(tag)<Props>()
Match Functions
Static methods on TaggedError are now standalone functions:
import { TaggedError } from "better-result" ;
TaggedError . match ( error , { ... })
TaggedError . matchPartial ( error , { ... }, fallback )
TaggedError . isTaggedError ( value )
Automated Migration
The fastest way to migrate is using the interactive CLI:
npx better-result migrate
This command:
Installs migration dependencies
Detects your AI agent configuration (OpenCode, Claude, Codex)
Installs the migration skill
Optionally launches your agent
Using the Migration Skill
Once installed, use the skill in your AI agent:
/skill better-result-migrate-v2
Then ask: “Migrate my TaggedError classes to v2”
The migration skill handles complex transformations including computed messages, validation logic, and import updates.
Manual Migration Steps
If you prefer manual migration, follow these steps:
Update class declarations
Convert class-based TaggedError to factory pattern: // Before
class MyError extends TaggedError {
readonly _tag = "MyError" as const ;
constructor ( readonly code : number ) {
super ( `Error ${ code } ` );
}
}
// After
class MyError extends TaggedError ( "MyError" )<{
code : number ;
message : string ;
}>() {
constructor ( args : { code : number }) {
super ({ ... args , message: `Error ${ args . code } ` });
}
}
Update constructor calls
Change from positional arguments to object syntax: // Before
throw new MyError ( 404 );
// After
throw new MyError ({ code: 404 });
Replace static methods
Update match functions: // Before
const msg = TaggedError . match ( error , {
NotFoundError : ( e ) => `Missing: ${ e . id } ` ,
ValidationError : ( e ) => `Invalid: ${ e . field } ` ,
});
// After
const msg = matchError ( error , {
NotFoundError : ( e ) => `Missing: ${ e . id } ` ,
ValidationError : ( e ) => `Invalid: ${ e . field } ` ,
});
Update imports
Add new exports to your imports: import {
TaggedError ,
matchError ,
matchErrorPartial ,
isTaggedError ,
} from "better-result" ;
Migration Patterns
Simple Class (No Constructor Logic)
For errors without custom constructor logic, remove the constructor entirely:
// 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: new FooError({ id: "123", message: "Foo: 123" })
Class with Computed Message
Keep custom constructor for derived messages:
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 } ` ,
});
}
}
// Usage: new NetworkError({ url: "/api", status: 404 })
Class with Validation
Preserve validation logic in the constructor:
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 } ` });
}
}
Class with Runtime Properties
Compute runtime values in constructor:
class TimestampedError extends TaggedError ( "TimestampedError" )<{
reason : string ;
timestamp : number ;
message : string ;
}>() {
constructor ( args : { reason : string }) {
super ({
... args ,
message: args . reason ,
timestamp: Date . now (),
});
}
}
New Features in v2
Simpler Error Definitions
No more boilerplate _tag declarations:
// Clean factory API
class MyError extends TaggedError ( "MyError" )<{
code : number ;
message : string ;
}>() {}
Dual-Style Match Functions
New match functions support both data-first and data-last (pipeable) styles:
// Data-first
matchError ( error , {
MyError : ( e ) => e . code ,
});
// Data-last (pipeable)
pipe (
error ,
matchError ({
MyError : ( e ) => e . code ,
})
);
Panic: Explicit Defect Handling
v2 introduces Panic for unrecoverable errors when user callbacks throw inside Result operations:
import { Panic , panic , isPanic } from "better-result" ;
// Callbacks that throw now cause Panic
try {
Result . ok ( 1 ). map (() => {
throw new Error ( "bug in map callback" );
});
} catch ( error ) {
if ( isPanic ( error )) {
console . error ( "Defect:" , error . message , error . cause );
}
}
// Generator cleanup throws → Panic
Result . gen ( function* () {
try {
yield * Result . err ( "expected failure" );
} finally {
throw new Error ( "cleanup bug" ); // throws Panic
}
});
// Manual panic for unrecoverable situations
panic ( "something went critically wrong" , cause );
Why Panic? Err is for recoverable domain errors. Panic is for bugs—like Rust’s panic!(). If your .map() callback throws, that’s not an error to handle, it’s a defect to fix. Returning Err would collapse type safety.
Panic detection:
if ( isPanic ( error )) { ... } // Type guard function
if ( Panic . is ( error )) { ... } // Static method
if ( error instanceof Panic ) { ... } // instanceof check
Complete Migration Example
View full before/after example
Before (v1): 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 } ` ,
});
After (v2): 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 } ` ,
});
Next Steps
After migration, explore v2’s enhanced features: