Skip to content

Commit

Permalink
Add generator (#24)
Browse files Browse the repository at this point in the history
* Start generator runner

* Update run

* Start async generator

* Update run

* Fix blocking in async generator

* Add test

* Remove todo

* Fix function return type

* Remove unused import

* Simplify runAsync

* Remove unnecessary transforms

* Add wrapper methods

* Fix tests

* Add docs

* Add benchmark script

* Remove unnecessary helper function
  • Loading branch information
bkiac authored Apr 1, 2024
1 parent dc5e3b5 commit e5531aa
Show file tree
Hide file tree
Showing 15 changed files with 553 additions and 187 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,22 @@ Similar libraries
Other useful libraries

- [ts-pattern](https://github.com/gvergnaud/ts-pattern)

## Testing

Adding an iterator to the Result class has introduced behavior that affects how testing libraries handle deep comparisons of instances of this class.
This is interfering with how deep equality checks are performed, as the tests rely on iterating over object properties or their prototypes to determine equality.

This means asserting equality between any two instances of the Result class will always pass, even if the instances are not equal:

```ts
expect(Ok()).toEqual(Ok(1))
expect(Err()).toEqual(Err(1))
expect(Ok()).toEqual(Err())
```

To properly test equality between instances of the Result class, you can unwrap the value and compare it directly:

```ts
expect(Ok().unwrap()).toEqual(Ok(1).unwrap()) // Now fails as expected
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"result-type"
],
"scripts": {
"benchmark": "tsx ./scripts/benchmark.ts",
"build": "tsup --config ./cfg/tsup.config.ts && pnpm build:ts",
"build:ts": "tsc --p ./cfg/tsconfig.types.json",
"format": "prettier --write .",
Expand Down
58 changes: 58 additions & 0 deletions scripts/benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Err, Ok, asyncFn, asyncGenFn} from "../src"
import {performance} from "perf_hooks"

const getOne = asyncFn(async () => Ok(1))

function formatTime(ms: number) {
return `${ms.toFixed(5)}ms`
}

const a = asyncFn(async () => {
const one = await getOne()
if (one.isErr) {
return one
}
const rand = Math.random()
if (Math.random() < 0.5) {
return Err("error")
}
return Ok(rand + one.value)
})

const b = asyncGenFn(async function* () {
const one = yield* getOne()
const rand = Math.random()
if (Math.random() < 0.5) {
yield* Err("error")
}
return rand + one
})

const iterations = 10_000_000

async function main() {
console.log("iterations:", iterations)

let start = performance.now()
for (let i = 0; i < iterations; i++) {
await a()
}
let end = performance.now()
const asyncDiff = end - start
const oneAsyncDiff = asyncDiff / iterations
console.log("asyncFn:", formatTime(oneAsyncDiff))

start = performance.now()
for (let i = 0; i < iterations; i++) {
await b()
}
end = performance.now()
const asyncGenDiff = end - start
const oneAsyncGenDiff = asyncGenDiff / iterations
console.log("asyncGenFn:", formatTime(oneAsyncGenDiff))

console.log("difference:", formatTime(oneAsyncGenDiff - oneAsyncDiff))
console.log("ratio:", (oneAsyncGenDiff / oneAsyncDiff).toFixed(2))
}

main()
57 changes: 57 additions & 0 deletions src/fn.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {type Result} from "./result"
import {ResultPromise} from "./result_promise"
import {run, runAsync} from "./run"
import type {InferErr, InferOk} from "./util"

/**
Expand Down Expand Up @@ -47,3 +48,59 @@ export function asyncFn(f: any): any {
return new ResultPromise(f(...args))
}
}

/**
* Wraps a generator function that returns a `Result` and infers its return type as `Result<T, E>`.
*
* `yield*` must be used to yield the result of a `Result`.
*
* **Examples**
*
* ```ts
* // $ExpectType (arg: number) => Result<number, string>
* const fn = genFn(function* (arg: number) {
* const a = yield* Ok(1)
* if (Math.random() > 0.5) {
* yield* Err("error")
* }
* return a + arg
* })
* ```
*/
export function genFn<A extends any[], R extends Result<any, any>, T>(
fn: (...args: A) => Generator<R, T, any>,
): (...args: A) => Result<T, InferErr<R>> {
return function (...args: any[]) {
return run(() => fn(...(args as A)))
}
}

/**
* Wraps an async generator function that returns a `Result` and infers its return type as `ResultPromise<T, E>`.
*
* `yield*` must be used to yield the result of a `Result`.
*
* **Examples**
*
* ```ts
* // $ExpectType (arg: number) => ResultPromise<number, string>
* const fn = asyncGenFn(async function* (arg: number) {
* const a = yield* Ok(1)
* if (Math.random() > 0.5) {
* yield* Err("error")
* }
* return a + arg
* })
* ```
*/
export function asyncGenFn<
A extends any[],
R extends ResultPromise<any, any> | Result<any, any>,
T,
>(
fn: (...args: A) => AsyncGenerator<R, T, any>,
): (...args: A) => ResultPromise<T, InferErr<Awaited<R>>> {
return function (...args: any[]) {
return runAsync(() => fn(...(args as A)))
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export * from "./option"
export * from "./error"
export * from "./result_promise"
export * from "./result"
export * from "./run"
export * from "./try"
export * from "./util"
5 changes: 5 additions & 0 deletions src/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export class ResultImpl<T, E> {
this.value = value
}

*[Symbol.iterator](): Iterator<Result<T, E>, T, any> {
const self = this as unknown as Result<T, E>
return yield self
}

/**
* Converts from `Result<T, E>` to `Option<T>`.
*
Expand Down
4 changes: 4 additions & 0 deletions src/result_promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export class ResultPromise<T, E> implements PromiseLike<Result<T, E>> {
readonly promise: Promise<Result<T, E>> | PromiseLike<Result<T, E>> | ResultPromise<T, E>,
) {}

*[Symbol.iterator](): Iterator<ResultPromise<T, E>, T, any> {
return yield this
}

then<A, B>(
successCallback?: (res: Result<T, E>) => A | PromiseLike<A>,
failureCallback?: (reason: unknown) => B | PromiseLike<B>,
Expand Down
108 changes: 108 additions & 0 deletions src/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {ResultPromise} from "./result_promise"
import {type Result, Ok, ResultImpl} from "./result"
import type {InferErr} from "./util"

function _run<T extends Result<any, any>, U>(
fn: () => Generator<T, U, any>,
): Result<U, InferErr<T>> {
const gen = fn()
let done = false
let returnResult = Ok()
while (!done) {
const iter = gen.next(returnResult.unwrap())
if (iter.value instanceof ResultImpl) {
if (iter.value.isErr) {
done = true
gen.return?.(iter.value as any)
}
returnResult = iter.value as any
} else {
done = true
returnResult = Ok(iter.value) as any
}
}
return returnResult as any
}

/**
* Runs a generator function that returns a `Result` and infers its return type as `Result<T, E>`.
*
* `yield*` must be used to yield the result of a `Result`.
*
* **Examples**
*
* ```ts
* // $ExpectType Result<number, string>
* const result = run(function* () {
* const a = yield* Ok(1)
* const random = Math.random()
* if (random > 0.5) {
* yield* Err("error")
* }
* return a + random
* })
* ```
*/
export function run<T extends Result<any, any>, U>(fn: () => Generator<T, U, any>) {
// Variable assignment helps with type inference
const result = _run(fn)
return result
}

async function toPromiseResult<T, E>(value: any): Promise<Result<T, E>> {
const awaited = await value
if (value instanceof ResultImpl) {
return awaited as any
}
return Ok(awaited)
}

function _runAsync<T extends ResultPromise<any, any> | Result<any, any>, U>(
fn: () => AsyncGenerator<T, U, any>,
): ResultPromise<U, InferErr<Awaited<T>>> {
const gen = fn()
const yieldedResultChain = Promise.resolve<Result<any, any>>(Ok()).then(
async function fulfill(nextResult): Promise<Result<any, any>> {
const iter = await gen.next(nextResult.unwrap())
const result = await toPromiseResult(iter.value)
if (iter.done) {
return result
}
if (result.isErr) {
gen.return?.(iter.value as any)
return result
}
return Promise.resolve(result).then(fulfill)
},
)
return new ResultPromise(yieldedResultChain)
}

/**
* Runs an async generator function that returns a `Result` and infers its return type as `ResultPromise<T, E>`.
*
* `yield*` must be used to yield the result of a `ResultPromise` or `Result`.
*
* **Examples**
*
* ```ts
* const okOne = () => new ResultPromise(Promise.resolve(Ok(1)))
*
* // $ExpectType ResultPromise<number, string>
* const result = runAsync(async function* () {
* const a = yield* okOne()
* const random = Math.random()
* if (random > 0.5) {
* yield* Err("error")
* }
* return a + random
* })
* ```
*/
export function runAsync<T extends ResultPromise<any, any> | Result<any, any>, U>(
fn: () => AsyncGenerator<T, U, any>,
) {
// Variable assignment helps with type inference
const result = _runAsync(fn)
return result
}
27 changes: 24 additions & 3 deletions test/fn.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {describe, expect, it, expectTypeOf} from "vitest"
import {describe, expect, it, expectTypeOf, test} from "vitest"
import {
asyncFn,
fn,
Ok,
Err,
tryAsyncFn,
type ResultPromise,
ResultPromise,
type Result,
tryFn,
ErrorWithTag,
genFn,
asyncGenFn,
CaughtError,
} from "../src"
import {TaggedError} from "./util"
Expand Down Expand Up @@ -234,3 +235,23 @@ describe.concurrent("asyncFn", () => {
})
})
})

test("genFn", () => {
const fn = genFn(function* (arg: number) {
const x = yield* Ok(arg)
const y = yield* Err(1)
const z = yield* Err("error")
return x + y
})
expectTypeOf(fn).toEqualTypeOf<(arg: number) => Result<number, number | string>>()
})

test("asyncGenFn", () => {
const fn = asyncGenFn(async function* (arg: number) {
const x = yield* new ResultPromise(Promise.resolve(Ok(arg)))
const y = yield* new ResultPromise(Promise.resolve(Err(1)))
const z = yield* new ResultPromise(Promise.resolve(Err("error")))
return x + y
})
expectTypeOf(fn).toEqualTypeOf<(arg: number) => ResultPromise<number, number | string>>()
})
8 changes: 4 additions & 4 deletions test/option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,24 @@ describe.concurrent("core", () => {
describe.concurrent("okOr", () => {
it("returns the value when called on a Some option", () => {
const option = TestSome(42)
expect(option.okOr("error")).toEqual(Ok(42))
expect(option.okOr("error").unwrap()).toEqual(42)
})

it("returns the error value when called on a None option", () => {
const option = TestNone<string>()
expect(option.okOr("error")).toEqual(Err("error"))
expect(option.okOr("error").unwrapErr()).toEqual("error")
})
})

describe.concurrent("okOrElse", () => {
it("returns the value when called on a Some option", () => {
const option = TestSome(42)
expect(option.okOrElse(() => "error")).toEqual(Ok(42))
expect(option.okOrElse(() => "error").unwrap()).toEqual(42)
})

it("returns the error value when called on a None option", () => {
const option = TestNone<string>()
expect(option.okOrElse(() => "error")).toEqual(Err("error"))
expect(option.okOrElse(() => "error").unwrapErr()).toEqual("error")
})
})

Expand Down
Loading

0 comments on commit e5531aa

Please sign in to comment.