Migrate from 2.x
Upgrade tagged error syntax, serialization, recovery inference, matching, collections, and retry configuration.
Upgrade the dependency, then let the compiler inventory changed call sites:
bun add better-result@latest
bun run check
TaggedError no longer has a trailing factory call
// Before
class NotFound extends TaggedError("NotFound")<{ id: string; message: string }>() {}
// Now
class NotFound extends TaggedError("NotFound")<{ id: string; message: string }> {}
TaggedErrorClass<Tag, Props> becomes TaggedErrorClass<Tag>; payload typing is applied by the subclass.
Replace unvalidated serialization helpers
Result.serialize, Result.deserialize, and Result.hydrate were removed. Define boundary-owned schemas:
const codec = Result.codec({
serialize: { ok: DomainToWireSchema, err: ErrorToWireSchema },
deserialize: { ok: DomainFromWireSchema, err: ErrorFromWireSchema },
});
const encoded = await codec.serialize(result);
const decoded = await codec.deserialize(input);
Handle ResultSerializationError and ResultDeserializationError. Schema async behavior controls operation async behavior. When you own both producer and consumer, version their schemas together, and treat contract mismatch as a defect, serializeUnsafe and deserializeUnsafe remove the codec-error handling layer and throw Panic instead. A valid decoded Err remains a domain Err. Keep safe methods at public, independently versioned, persisted, or otherwise untrusted boundaries.
Recovery can widen success
const result: ResultType<User, NotFound> = findUser(id);
const recovered = result.tryRecover(() => Result.ok(guestId));
// Result<User | GuestId, never>
Do not cast away the widened union. Narrow it or return a common domain type.
Matching infers handler return unions
matchError and matchErrorPartial retain different handler return types. matchErrorPartial may omit its fallback; unhandled variants then pass through unchanged.
const output = matchErrorPartial(error, {
NotFound: () => 404,
});
// 404 | unhandled error variants
TaggedError instance matching
Tagged errors can now match their union directly. This is additive; existing matchError calls remain valid.
const response = result.match({
ok: (user) => ({ status: 200, body: user }),
err: (error) =>
error.match({
UserNotFound: () => ({ status: 404, body: null }),
DatabaseUnavailable: () => ({ status: 503, body: null }),
}),
});
Use matchError for structurally tagged errors or data-last matching. Both exhaustive forms turn a selected handler exception into Panic and preserve the exception as cause.
match is now a reserved TaggedError instance name. TypeScript rejects payload properties and incompatible subclass members with that name; rename any collision during migration.
Collection additions
Result.allpreserves tuple success types and returns the first error.Result.allAsyncawaits concurrently and preserves input-order error selection.Result.partitionsupports heterogeneous Results.Result.partitionAsyncawaits concurrently and partitions both branches.
Retry additions
Existing bounded static retry policies remain valid. Async retries also support:
TryPromiseContext.signal;- top-level
signalcancellation; - dynamic
delayMs(error, context); - static
jitter; shouldRetry(error, context).
Dynamic delays cannot use backoff or jitter.
Repository skill
For a systematic migration, use the repository’s portable migrate-better-result-3 skill. It inventories affected APIs, includes a safe TaggedError codemod, and treats the compiler as the migration ledger.
npx skills add dmmulroy/better-result@migrate-better-result-3