Created
July 2, 2026 14:34
-
-
Save isaacharrisholt/997a17a03a3080cac6bb187e22406062 to your computer and use it in GitHub Desktop.
Serialisable TypeScript results, inspired by better-result
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| /** Discriminated union representing success or failure */ | |
| export type Result<T, E> = | |
| | { readonly ok: true; readonly value: T } | |
| | { readonly ok: false; readonly error: E }; | |
| /** Infer success type from Result */ | |
| export type InferOk<R> = R extends { ok: true; value: infer T } ? T : never; | |
| /** Infer error type from Result */ | |
| export type InferErr<R> = R extends { ok: false; error: infer E } ? E : never; | |
| /** Error marker for generator yields */ | |
| export type YieldErr<E> = { readonly ok: false; readonly error: E }; | |
| /** Extracts error type from yield union in Result.gen */ | |
| type InferYieldErr<Y> = Y extends { ok: false; error: infer E } ? E : never; | |
| /** Constraint for any Result */ | |
| type AnyResult = Result<unknown, unknown>; | |
| /** Extract Ok value type from Promise<Result<T, E>> */ | |
| type AwaitedOk<T> = T extends Promise<Result<infer V, unknown>> ? V : never; | |
| /** Extract Err type from Promise<Result<T, E>> */ | |
| type AwaitedErr<T> = T extends Promise<Result<unknown, infer E>> ? E : never; | |
| // --------------------------------------------------------------------------- | |
| // Helpers | |
| // --------------------------------------------------------------------------- | |
| /* | |
| * Creates data-first/data-last dual function. | |
| * | |
| * @param arity Number of args for data-first form. | |
| * @param body Implementation function. | |
| * @returns Function supporting both calling conventions. | |
| * | |
| * @example | |
| * const add: { | |
| * (a: number, b: number): number; | |
| * (b: number): (a: number) => number; | |
| * } = dual(2, (a: number, b: number) => a + b); | |
| * | |
| * add(1, 2); // 3 (data-first) | |
| * add(2)(1); // 3 (data-last) | |
| */ | |
| export function dual< | |
| DataLast extends (...args: Array<any>) => any, | |
| DataFirst extends (...args: Array<any>) => any, | |
| >(arity: Parameters<DataFirst>['length'], body: DataFirst): DataLast & DataFirst { | |
| if (arity === 2) { | |
| return ((...args: Array<any>) => { | |
| if (args.length >= 2) { | |
| return body(args[0], args[1]); | |
| } | |
| return (self: any) => body(self, args[0]); | |
| }) as DataLast & DataFirst; | |
| } | |
| if (arity === 3) { | |
| return ((...args: Array<any>) => { | |
| if (args.length >= 3) { | |
| return body(args[0], args[1], args[2]); | |
| } | |
| return (self: any) => body(self, args[0], args[1]); | |
| }) as DataLast & DataFirst; | |
| } | |
| if (arity === 4) { | |
| return ((...args: Array<any>) => { | |
| if (args.length >= 4) { | |
| return body(args[0], args[1], args[2], args[3]); | |
| } | |
| return (self: any) => body(self, args[0], args[1], args[2]); | |
| }) as DataLast & DataFirst; | |
| } | |
| return ((...args: Array<any>) => { | |
| if (args.length >= arity) { | |
| return body(...args); | |
| } | |
| return (self: any) => body(self, ...args); | |
| }) as DataLast & DataFirst; | |
| } | |
| /** Executes fn, panics if it throws */ | |
| const tryOrPanic = <T>(fn: () => T, message: string): T => { | |
| try { | |
| return fn(); | |
| } catch (cause) { | |
| throw panic(message, cause); | |
| } | |
| }; | |
| /** Async version of tryOrPanic */ | |
| const tryOrPanicAsync = async <T>(fn: () => Promise<T>, message: string): Promise<T> => { | |
| try { | |
| return await fn(); | |
| } catch (cause) { | |
| throw panic(message, cause); | |
| } | |
| }; | |
| /** Validates that a value is a Result. Throws with helpful message if not. */ | |
| function assertIsResult(value: unknown): asserts value is Result<unknown, unknown> { | |
| if ( | |
| value !== null && | |
| typeof value === 'object' && | |
| 'ok' in value && | |
| typeof value.ok === 'boolean' | |
| ) { | |
| return; | |
| } | |
| throw panic( | |
| 'Result.gen body must return Result.ok() or Result.err(), got: ' + | |
| (value === null | |
| ? 'null' | |
| : typeof value === 'object' | |
| ? JSON.stringify(value) | |
| : String(value)), | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Errors | |
| // --------------------------------------------------------------------------- | |
| /** Serialise cause for JSON output */ | |
| const serialiseCause = (cause: unknown): unknown => { | |
| if (cause instanceof Error) { | |
| return { name: cause.name, message: cause.message, stack: cause.stack }; | |
| } | |
| return cause; | |
| }; | |
| /** Any tagged error (for generic constraints) */ | |
| type AnyTaggedError = Error & { readonly _tag: string }; | |
| /** Type guard for any tagged error */ | |
| const isAnyTaggedError = (value: unknown): value is AnyTaggedError => { | |
| return value instanceof Error && '_tag' in value && typeof value._tag === 'string'; | |
| }; | |
| /** | |
| * Factory for tagged error classes. | |
| * | |
| * @example | |
| * class NotFoundError extends TaggedError("NotFoundError")<{ | |
| * id: string; | |
| * message: string; | |
| * }> {} | |
| * | |
| * const err = new NotFoundError({ id: "123", message: "Not found: 123" }); | |
| * err._tag // "NotFoundError" | |
| * err.id // "123" | |
| * err.message // "Not found: 123" | |
| * | |
| * // Check if any tagged error | |
| * TaggedError.is(err) // true | |
| */ | |
| export const TaggedError: { | |
| <Tag extends string>( | |
| tag: Tag, | |
| ): < | |
| Props extends Record<string, unknown> = Record<string, never>, | |
| >() => TaggedErrorClass<Tag, Props>; | |
| /** Type guard for any TaggedError instance */ | |
| is(value: unknown): value is AnyTaggedError; | |
| } = Object.assign( | |
| <Tag extends string>(tag: Tag) => | |
| <Props extends Record<string, unknown> = Record<string, never>>(): TaggedErrorClass< | |
| Tag, | |
| Props | |
| > => { | |
| class Base extends Error { | |
| readonly _tag: Tag = tag; | |
| /** Type guard for this error class */ | |
| static is(value: unknown): value is Base { | |
| return value instanceof Base; | |
| } | |
| constructor(args?: Props) { | |
| const message = | |
| args && 'message' in args && typeof args.message === 'string' | |
| ? args.message | |
| : undefined; | |
| const cause = args && 'cause' in args ? args.cause : undefined; | |
| super(message, cause !== undefined ? { cause } : undefined); | |
| if (args) { | |
| Object.assign(this, args); | |
| } | |
| Object.setPrototypeOf(this, new.target.prototype); | |
| this.name = tag; | |
| if (cause instanceof Error && cause.stack) { | |
| const indented = cause.stack.replace(/\n/g, '\n '); | |
| this.stack = `${this.stack}\nCaused by: ${indented}`; | |
| } | |
| } | |
| toJSON(): object { | |
| return { | |
| ...this, | |
| _tag: this._tag, | |
| name: this.name, | |
| message: this.message, | |
| cause: serialiseCause(this.cause), | |
| stack: this.stack, | |
| }; | |
| } | |
| } | |
| // SAFETY: Cast needed for factory pattern - Props are assigned via Object.assign | |
| return Base as unknown as TaggedErrorClass<Tag, Props>; | |
| }, | |
| { is: isAnyTaggedError }, | |
| ); | |
| /** Instance type produced by TaggedError factory */ | |
| export type TaggedErrorInstance<Tag extends string, Props> = Error & { | |
| readonly _tag: Tag; | |
| toJSON(): object; | |
| } & Readonly<Props>; | |
| /** Class type produced by TaggedError factory */ | |
| export type TaggedErrorClass<Tag extends string, Props> = { | |
| new ( | |
| ...args: keyof Props extends never ? [args?: Record<string, never>] : [args: Props] | |
| ): TaggedErrorInstance<Tag, Props>; | |
| /** Type guard for this error class */ | |
| is(value: unknown): value is TaggedErrorInstance<Tag, Props>; | |
| }; | |
| /** Handler map for exhaustive matching */ | |
| export type MatchHandlers<E extends AnyTaggedError, R> = { | |
| [K in E['_tag']]: (err: Extract<E, { _tag: K }>) => R; | |
| }; | |
| /** | |
| * Exhaustive pattern match on tagged error union. | |
| * | |
| * @example | |
| * // Data-first | |
| * matchError(err, { | |
| * NotFoundError: (e) => `Missing: ${e.id}`, | |
| * ValidationError: (e) => `Invalid: ${e.field}`, | |
| * }); | |
| * | |
| * // Data-last (pipeable) | |
| * pipe(err, matchError({ | |
| * NotFoundError: (e) => `Missing: ${e.id}`, | |
| * ValidationError: (e) => `Invalid: ${e.field}`, | |
| * })); | |
| */ | |
| export const matchError: { | |
| <E extends AnyTaggedError, R>(err: E, handlers: MatchHandlers<E, R>): R; | |
| <E extends AnyTaggedError, R>(handlers: MatchHandlers<E, R>): (err: E) => R; | |
| } = dual(2, <E extends AnyTaggedError, R>(err: E, handlers: MatchHandlers<E, R>): R => { | |
| const handler = handlers[err._tag as E['_tag']]; | |
| // SAFETY: handler exists if handlers satisfies MatchHandlers<E, R> | |
| return handler(err as Extract<E, { _tag: (typeof err)['_tag'] }>); | |
| }); | |
| /** | |
| * Partial pattern match with fallback for unhandled tags. | |
| * | |
| * @example | |
| * matchErrorPartial(err, { | |
| * NotFoundError: (e) => `Missing: ${e.id}`, | |
| * }, (e) => `Unknown: ${e.message}`); | |
| */ | |
| export const matchErrorPartial: { | |
| <E extends AnyTaggedError, R>( | |
| err: E, | |
| handlers: Partial<MatchHandlers<E, R>>, | |
| fallback: (e: E) => R, | |
| ): R; | |
| <E extends AnyTaggedError, R>( | |
| handlers: Partial<MatchHandlers<E, R>>, | |
| fallback: (e: E) => R, | |
| ): (err: E) => R; | |
| } = dual( | |
| 3, | |
| <E extends AnyTaggedError, R>( | |
| err: E, | |
| handlers: Partial<MatchHandlers<E, R>>, | |
| fallback: (e: E) => R, | |
| ): R => { | |
| const handler = handlers[err._tag as E['_tag']]; | |
| if (handler) { | |
| // SAFETY: handler exists and matches the tag | |
| return handler(err as Extract<E, { _tag: (typeof err)['_tag'] }>); | |
| } | |
| return fallback(err); | |
| }, | |
| ); | |
| /** | |
| * Type guard for tagged error instances. | |
| * | |
| * @example | |
| * if (isTaggedError(value)) { value._tag } | |
| */ | |
| export const isTaggedError = isAnyTaggedError; | |
| /** | |
| * Wraps exceptions caught by Result.try/tryPromise. | |
| * Custom constructor derives message from cause. | |
| */ | |
| export class UnhandledException extends TaggedError('UnhandledException')<{ | |
| message: string; | |
| cause: unknown; | |
| }>() { | |
| constructor(args: { cause: unknown }) { | |
| const message = | |
| args.cause instanceof Error | |
| ? `Unhandled exception: ${args.cause.message}` | |
| : `Unhandled exception: ${String(args.cause)}`; | |
| super({ message, cause: args.cause }); | |
| } | |
| } | |
| /** | |
| * Unrecoverable error - user code threw inside Result operations. | |
| * | |
| * @example | |
| * // Panic in generator cleanup: | |
| * Result.gen(function* () { | |
| * try { | |
| * yield* Result.run(Result.err("expected error")); | |
| * } finally { | |
| * throw new Error("cleanup failed"); // Panic! | |
| * } | |
| * }); | |
| * | |
| * // Panic in combinator: | |
| * Result.map(Result.ok(1), () => { throw new Error("oops"); }); // Panic! | |
| */ | |
| export class Panic extends TaggedError('Panic')<{ | |
| message: string; | |
| cause?: unknown; | |
| }>() {} | |
| /** | |
| * Type guard for Panic instances. | |
| * | |
| * @example | |
| * if (isPanic(value)) { value.cause } | |
| */ | |
| export const isPanic = (value: unknown): value is Panic => { | |
| return value instanceof Panic; | |
| }; | |
| /** | |
| * Throw an unrecoverable Panic. | |
| * | |
| * @example | |
| * panic("something went wrong", cause); | |
| */ | |
| export const panic = (message: string, cause?: unknown): never => { | |
| throw new Panic({ message, cause }); | |
| }; | |
| export class Unreachable extends TaggedError('Unreachable')<{ | |
| message: string; | |
| }>() {} | |
| /** | |
| * Throw an unreachable error. | |
| * | |
| * @example | |
| * unreachable("this should never happen"); | |
| */ | |
| export const unreachable = (message: string): never => { | |
| throw new Unreachable({ message }); | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Constructors | |
| // --------------------------------------------------------------------------- | |
| const ok = <T, E = never>(value: T): Result<T, E> => ({ ok: true, value }); | |
| const err = <T = never, E = unknown>(error: E): Result<T, E> => ({ ok: false, error }); | |
| // --------------------------------------------------------------------------- | |
| // Transformers | |
| // --------------------------------------------------------------------------- | |
| const map: { | |
| <A, B, E>(result: Result<A, E>, fn: (a: A) => B): Result<B, E>; | |
| <A, B>(fn: (a: A) => B): <E>(result: Result<A, E>) => Result<B, E>; | |
| } = dual(2, <A, B, E>(result: Result<A, E>, fn: (a: A) => B): Result<B, E> => { | |
| if (!result.ok) return result; | |
| return tryOrPanic(() => ok(fn(result.value)), 'map callback threw'); | |
| }); | |
| const mapError: { | |
| <A, E, E2>(result: Result<A, E>, fn: (e: E) => E2): Result<A, E2>; | |
| <E, E2>(fn: (e: E) => E2): <A>(result: Result<A, E>) => Result<A, E2>; | |
| } = dual(2, <A, E, E2>(result: Result<A, E>, fn: (e: E) => E2): Result<A, E2> => { | |
| if (result.ok) return result; | |
| return tryOrPanic(() => err(fn(result.error)), 'mapError callback threw'); | |
| }); | |
| const andThen: { | |
| <A, B, E, E2>(result: Result<A, E>, fn: (a: A) => Result<B, E2>): Result<B, E | E2>; | |
| <A, B, E2>(fn: (a: A) => Result<B, E2>): <E>(result: Result<A, E>) => Result<B, E | E2>; | |
| } = dual( | |
| 2, | |
| <A, B, E, E2>(result: Result<A, E>, fn: (a: A) => Result<B, E2>): Result<B, E | E2> => { | |
| if (!result.ok) return result; | |
| return tryOrPanic(() => fn(result.value), 'andThen callback threw'); | |
| }, | |
| ); | |
| const andThenAsync: { | |
| <A, B, E, E2>( | |
| result: Result<A, E>, | |
| fn: (a: A) => Promise<Result<B, E2>>, | |
| ): Promise<Result<B, E | E2>>; | |
| <A, B, E2>( | |
| fn: (a: A) => Promise<Result<B, E2>>, | |
| ): <E>(result: Result<A, E>) => Promise<Result<B, E | E2>>; | |
| } = dual( | |
| 2, | |
| async <A, B, E, E2>( | |
| result: Result<A, E>, | |
| fn: (a: A) => Promise<Result<B, E2>>, | |
| ): Promise<Result<B, E | E2>> => { | |
| if (!result.ok) return result; | |
| return tryOrPanicAsync(() => fn(result.value), 'andThenAsync callback threw'); | |
| }, | |
| ); | |
| // --------------------------------------------------------------------------- | |
| // Pattern Matching | |
| // --------------------------------------------------------------------------- | |
| const match: { | |
| <A, E, T>(result: Result<A, E>, handlers: { ok: (a: A) => T; err: (e: E) => T }): T; | |
| <A, E, T>(handlers: { ok: (a: A) => T; err: (e: E) => T }): (result: Result<A, E>) => T; | |
| } = dual( | |
| 2, | |
| <A, E, T>(result: Result<A, E>, handlers: { ok: (a: A) => T; err: (e: E) => T }): T => { | |
| if (result.ok) { | |
| return tryOrPanic(() => handlers.ok(result.value), 'match ok handler threw'); | |
| } | |
| return tryOrPanic(() => handlers.err(result.error), 'match err handler threw'); | |
| }, | |
| ); | |
| // --------------------------------------------------------------------------- | |
| // Side Effects | |
| // --------------------------------------------------------------------------- | |
| const tap: { | |
| <A, E>(result: Result<A, E>, fn: (a: A) => void): Result<A, E>; | |
| <A>(fn: (a: A) => void): <E>(result: Result<A, E>) => Result<A, E>; | |
| } = dual(2, <A, E>(result: Result<A, E>, fn: (a: A) => void): Result<A, E> => { | |
| if (!result.ok) return result; | |
| return tryOrPanic(() => { | |
| fn(result.value); | |
| return result; | |
| }, 'tap callback threw'); | |
| }); | |
| const tapAsync: { | |
| <A, E>(result: Result<A, E>, fn: (a: A) => Promise<void>): Promise<Result<A, E>>; | |
| <A>(fn: (a: A) => Promise<void>): <E>(result: Result<A, E>) => Promise<Result<A, E>>; | |
| } = dual( | |
| 2, | |
| async <A, E>( | |
| result: Result<A, E>, | |
| fn: (a: A) => Promise<void>, | |
| ): Promise<Result<A, E>> => { | |
| if (!result.ok) return result; | |
| return tryOrPanicAsync(async () => { | |
| await fn(result.value); | |
| return result; | |
| }, 'tapAsync callback threw'); | |
| }, | |
| ); | |
| // --------------------------------------------------------------------------- | |
| // Unwrapping | |
| // --------------------------------------------------------------------------- | |
| const unwrap = <A, E>(result: Result<A, E>, message?: string): A => { | |
| if (result.ok) return result.value; | |
| throw panic(message ?? `Unwrap called on Err: ${String(result.error)}`, result.error); | |
| }; | |
| const unwrapOr: { | |
| <A, E, B>(result: Result<A, E>, fallback: B): A | B; | |
| <B>(fallback: B): <A, E>(result: Result<A, E>) => A | B; | |
| } = dual(2, <A, E, B>(result: Result<A, E>, fallback: B): A | B => { | |
| return result.ok ? result.value : fallback; | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Exception Handling | |
| // --------------------------------------------------------------------------- | |
| const tryFn: { | |
| <A>( | |
| thunk: () => A, | |
| config?: { retry?: { times: number } }, | |
| ): Result<A, UnhandledException>; | |
| <A, E>( | |
| options: { try: () => A; catch: (cause: unknown) => E }, | |
| config?: { retry?: { times: number } }, | |
| ): Result<A, E>; | |
| } = <A, E>( | |
| options: (() => A) | { try: () => A; catch: (cause: unknown) => E }, | |
| config?: { retry?: { times: number } }, | |
| ): Result<A, E | UnhandledException> => { | |
| const execute = (): Result<A, E | UnhandledException> => { | |
| if (typeof options === 'function') { | |
| try { | |
| return ok(options()); | |
| } catch (cause) { | |
| return err(new UnhandledException({ cause })); | |
| } | |
| } | |
| try { | |
| return ok(options.try()); | |
| } catch (originalCause) { | |
| try { | |
| return err(options.catch(originalCause)); | |
| } catch (catchHandlerError) { | |
| throw panic('Result.try catch handler threw', catchHandlerError); | |
| } | |
| } | |
| }; | |
| const times = config?.retry?.times ?? 0; | |
| let result = execute(); | |
| for (let retry = 0; retry < times && !result.ok; retry++) { | |
| result = execute(); | |
| } | |
| return result; | |
| }; | |
| type RetryConfig<E = unknown> = { | |
| retry?: { | |
| times: number; | |
| delayMs: number; | |
| backoff: 'linear' | 'constant' | 'exponential'; | |
| /** Predicate to determine if an error should trigger a retry. Defaults to always retry. */ | |
| shouldRetry?: (error: E) => boolean; | |
| }; | |
| }; | |
| const tryPromise: { | |
| <A>( | |
| thunk: () => Promise<A>, | |
| config?: RetryConfig<UnhandledException>, | |
| ): Promise<Result<A, UnhandledException>>; | |
| <A, E>( | |
| options: { | |
| try: () => Promise<A>; | |
| catch: (cause: unknown) => E | Promise<E>; | |
| }, | |
| config?: RetryConfig<E>, | |
| ): Promise<Result<A, E>>; | |
| } = async <A, E>( | |
| options: | |
| | (() => Promise<A>) | |
| | { try: () => Promise<A>; catch: (cause: unknown) => E | Promise<E> }, | |
| config?: RetryConfig<E | UnhandledException>, | |
| ): Promise<Result<A, E | UnhandledException>> => { | |
| const execute = async (): Promise<Result<A, E | UnhandledException>> => { | |
| if (typeof options === 'function') { | |
| try { | |
| return ok(await options()); | |
| } catch (cause) { | |
| return err(new UnhandledException({ cause })); | |
| } | |
| } | |
| try { | |
| return ok(await options.try()); | |
| } catch (originalCause) { | |
| try { | |
| return err(await options.catch(originalCause)); | |
| } catch (catchHandlerError) { | |
| throw panic('Result.tryPromise catch handler threw', catchHandlerError); | |
| } | |
| } | |
| }; | |
| const retry = config?.retry; | |
| if (!retry) { | |
| return execute(); | |
| } | |
| const getDelay = (retryAttempt: number): number => { | |
| switch (retry.backoff) { | |
| case 'constant': | |
| return retry.delayMs; | |
| case 'linear': | |
| return retry.delayMs * (retryAttempt + 1); | |
| case 'exponential': | |
| return retry.delayMs * 2 ** retryAttempt; | |
| } | |
| }; | |
| const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); | |
| let result = await execute(); | |
| const shouldRetryFn = retry.shouldRetry ?? (() => true); | |
| for (let attempt = 0; attempt < retry.times; attempt++) { | |
| if (result.ok) break; | |
| const error = result.error; | |
| const shouldContinue = tryOrPanic( | |
| () => shouldRetryFn(error), | |
| 'shouldRetry predicate threw', | |
| ); | |
| if (!shouldContinue) break; | |
| await sleep(getDelay(attempt)); | |
| result = await execute(); | |
| } | |
| return result; | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Generator Composition | |
| // --------------------------------------------------------------------------- | |
| /** Wrap sync Result for yield* in generators */ | |
| function* run<T, E>(result: Result<T, E>): Generator<YieldErr<E>, T, unknown> { | |
| if (result.ok) { | |
| return result.value; | |
| } | |
| yield result; | |
| throw panic( | |
| 'Unreachable: Err yielded in Result.gen but generator continued', | |
| result.error, | |
| ); | |
| } | |
| /** Wrap Promise<Result> for yield* in async generators */ | |
| async function* resultAwait<T, E>( | |
| promise: Promise<Result<T, E>>, | |
| ): AsyncGenerator<YieldErr<E>, T, unknown> { | |
| const result = await promise; | |
| return yield* run(result); | |
| } | |
| /** Wrap array of Promise<Result> for parallel execution, like Promise.all */ | |
| async function* resultAwaitAll< | |
| const T extends readonly Promise<Result<unknown, unknown>>[], | |
| >( | |
| promises: T, | |
| ): AsyncGenerator< | |
| YieldErr<AwaitedErr<T[number]>>, | |
| { -readonly [K in keyof T]: AwaitedOk<T[K]> }, | |
| unknown | |
| > { | |
| const results = await Promise.all(promises); | |
| // Check for first error | |
| for (const result of results) { | |
| if (!result.ok) { | |
| return yield* run(result as Result<never, AwaitedErr<T[number]>>); | |
| } | |
| } | |
| // All succeeded - extract values preserving tuple structure | |
| return results.map((r) => (r as { ok: true; value: unknown }).value) as { | |
| -readonly [K in keyof T]: AwaitedOk<T[K]>; | |
| }; | |
| } | |
| const gen: { | |
| <Yield extends YieldErr<unknown>, R extends AnyResult>( | |
| body: () => Generator<Yield, R, unknown>, | |
| ): Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>; | |
| <Yield extends YieldErr<unknown>, R extends AnyResult, This>( | |
| body: (this: This) => Generator<Yield, R, unknown>, | |
| thisArg: This, | |
| ): Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>; | |
| <Yield extends YieldErr<unknown>, R extends AnyResult>( | |
| body: () => AsyncGenerator<Yield, R, unknown>, | |
| ): Promise<Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>>; | |
| <Yield extends YieldErr<unknown>, R extends AnyResult, This>( | |
| body: (this: This) => AsyncGenerator<Yield, R, unknown>, | |
| thisArg: This, | |
| ): Promise<Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>>; | |
| } = (<Yield extends YieldErr<unknown>, R extends AnyResult, This>( | |
| body: | |
| | (() => Generator<Yield, R, unknown>) | |
| | (() => AsyncGenerator<Yield, R, unknown>) | |
| | ((this: This) => Generator<Yield, R, unknown>) | |
| | ((this: This) => AsyncGenerator<Yield, R, unknown>), | |
| thisArg?: This, | |
| ): | |
| | Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>> | |
| | Promise<Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>> => { | |
| // SAFETY: body.call binds thisArg; cast needed due to union of function signatures | |
| const iterator = (body as (this: This) => Generator<Yield, R, unknown>).call( | |
| thisArg as This, | |
| ); | |
| // Detect async generator via Symbol.asyncIterator | |
| if (Symbol.asyncIterator in iterator) { | |
| return (async () => { | |
| // SAFETY: Async check above guarantees this is an async generator | |
| const asyncIter = iterator as unknown as AsyncGenerator<Yield, R, unknown>; | |
| let state: IteratorResult<Yield, R>; | |
| try { | |
| state = await asyncIter.next(); | |
| } catch (cause) { | |
| // Generator body threw before yielding (user code error or cleanup on success path) | |
| throw panic('generator body threw', cause); | |
| } | |
| assertIsResult(state.value); | |
| if (!state.done) { | |
| // Generator yielded an error result. Attempt cleanup via .return() but | |
| // tolerate failures: JS async generators propagating .return() through | |
| // yield* can trigger user code (e.g. destructuring) that throws. Since | |
| // we already have the error result, cleanup failure is non-fatal. | |
| try { | |
| await asyncIter.return?.(undefined as unknown as R); | |
| } catch { | |
| // Cleanup failed, but we have the error result - continue | |
| } | |
| } | |
| return state.value as Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>; | |
| })(); | |
| } | |
| // Sync generator | |
| // SAFETY: If not async, must be sync generator | |
| const syncIter = iterator as Generator<Yield, R, unknown>; | |
| let state: IteratorResult<Yield, R>; | |
| try { | |
| state = syncIter.next(); | |
| } catch (cause) { | |
| // Generator body threw before yielding (user code error or cleanup on success path) | |
| throw panic('generator body threw', cause); | |
| } | |
| assertIsResult(state.value); | |
| if (!state.done) { | |
| // Generator yielded an error result. Attempt cleanup via .return() but | |
| // tolerate failures: sync generators propagating .return() through yield* | |
| // can trigger user code (e.g. destructuring) that throws. Since we already | |
| // have the error result, cleanup failure is non-fatal. | |
| try { | |
| syncIter.return?.(undefined as unknown as R); | |
| } catch { | |
| // Cleanup failed, but we have the error result - continue | |
| } | |
| } | |
| return state.value as Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>; | |
| }) as { | |
| <Yield extends YieldErr<unknown>, R extends AnyResult>( | |
| body: () => Generator<Yield, R, unknown>, | |
| ): Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>; | |
| <Yield extends YieldErr<unknown>, R extends AnyResult, This>( | |
| body: (this: This) => Generator<Yield, R, unknown>, | |
| thisArg: This, | |
| ): Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>; | |
| <Yield extends YieldErr<unknown>, R extends AnyResult>( | |
| body: () => AsyncGenerator<Yield, R, unknown>, | |
| ): Promise<Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>>; | |
| <Yield extends YieldErr<unknown>, R extends AnyResult, This>( | |
| body: (this: This) => AsyncGenerator<Yield, R, unknown>, | |
| thisArg: This, | |
| ): Promise<Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>>; | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Utilities | |
| // --------------------------------------------------------------------------- | |
| const partition = <T, E>(results: readonly Result<T, E>[]): [T[], E[]] => { | |
| const oks: T[] = []; | |
| const errs: E[] = []; | |
| for (const r of results) { | |
| if (r.ok) { | |
| oks.push(r.value); | |
| } else { | |
| errs.push(r.error); | |
| } | |
| } | |
| return [oks, errs]; | |
| }; | |
| const flatten = <T, E, E2>(result: Result<Result<T, E>, E2>): Result<T, E | E2> => { | |
| if (result.ok) return result.value; | |
| return result; | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Export | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Utilities for creating and handling Result types. | |
| * | |
| * @example | |
| * const result = Result.try(() => JSON.parse(str)); | |
| * const value = Result.unwrapOr(Result.map(result, x => x.id), "default"); | |
| */ | |
| export const Result = { | |
| /** Creates successful result. */ | |
| ok, | |
| /** Creates error result. */ | |
| err, | |
| /** Executes sync function, wraps result/error in Result. */ | |
| try: tryFn, | |
| /** Executes async function, wraps result/error in Result with retry support. */ | |
| tryPromise, | |
| /** Transforms success value, passes error through. */ | |
| map, | |
| /** Transforms error value, passes success through. */ | |
| mapError, | |
| /** Chains Result-returning function on success. */ | |
| andThen, | |
| /** Chains async Result-returning function on success. */ | |
| andThenAsync, | |
| /** Pattern matches on Result. */ | |
| match, | |
| /** Runs side effect on success value, returns original result. */ | |
| tap, | |
| /** Runs async side effect on success value, returns original result. */ | |
| tapAsync, | |
| /** Extracts value or throws. */ | |
| unwrap, | |
| /** Extracts value or returns fallback. */ | |
| unwrapOr, | |
| /** Generator-based composition for Result types. */ | |
| gen, | |
| /** Wraps Promise<Result> to be yieldable in async Result.gen blocks. */ | |
| await: resultAwait, | |
| /** Wraps array of Promise<Result> for parallel execution with tuple typing. */ | |
| awaitAll: resultAwaitAll, | |
| /** Wraps sync Result to be yieldable in Result.gen blocks. */ | |
| run, | |
| /** Splits array of Results into tuple of [okValues, errorValues]. */ | |
| partition, | |
| /** Flattens nested Result into single Result. */ | |
| flatten, | |
| } as const; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment