Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/effectify' into explore
Browse files Browse the repository at this point in the history
  • Loading branch information
Tim Smart committed Jan 24, 2023
2 parents ba3301f + 5f20b20 commit f160a14
Show file tree
Hide file tree
Showing 3 changed files with 340 additions and 0 deletions.
129 changes: 129 additions & 0 deletions src/effectify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as Z from "@effect/io/Effect"
import { CustomPromisifyLegacy, CustomPromisifySymbol } from "node:util"

type Callback<E, A> = (e: E, a: A) => void

type Fn = (...args: any) => any

export type CustomPromisify<TCustom extends Fn> =
| CustomPromisifySymbol<TCustom>
| CustomPromisifyLegacy<TCustom>

type Parameters<F extends Function> = F extends (...args: infer P) => any ? P : never

export type Length<L extends unknown[]> = L["length"]

export type Tail<L extends unknown[]> = L extends readonly []
? L
: L extends readonly [unknown?, ...infer LTail]
? LTail
: L

export type Last<L extends unknown[]> = L[Length<Tail<L>>]

export type UnwrapPromise<T> = T extends Promise<infer A> ? A : never

export function effectify<
X extends Fn,
F extends CustomPromisify<X>,
Cb = Last<Parameters<F>>,
E = Cb extends Function ? NonNullable<Parameters<Cb>[0]> : never
>(
fn: F
): (
...args: F extends CustomPromisify<infer TCustom> ? Parameters<TCustom> : never[]
) => Z.Effect<
never,
E,
F extends CustomPromisify<infer TCustom> ? UnwrapPromise<ReturnType<TCustom>> : never
>

export function effectify<E, A>(
fn: (cb: Callback<E, A>) => void
): () => Z.Effect<never, NonNullable<E>, A>

export function effectify<E, A, X1>(
fn: (x1: X1, cb: Callback<E, A>) => void
): (x1: X1) => Z.Effect<never, NonNullable<E>, A>

export function effectify<E, A, X1, X2>(
fn: (x1: X1, x2: X2, cb: Callback<E, A>) => void
): (x1: X1, x2: X2) => Z.Effect<never, NonNullable<E>, A>

export function effectify<E, A, X1, X2, X3>(
fn: (x1: X1, x2: X2, x3: X3, cb: Callback<E, A>) => void
): (x1: X1, x2: X2, x3: X3) => Z.Effect<never, NonNullable<E>, A>

export function effectify<E, A, X1, X2, X3, X4>(
fn: (x1: X1, x2: X2, x3: X3, x4: X4, cb: Callback<E, A>) => void
): (x1: X1, x2: X2, x3: X3, x4: X4) => Z.Effect<never, NonNullable<E>, A>

export function effectify<E, A, X1, X2, X3, X4, X5>(
fn: (x1: X1, x2: X2, x3: X3, x4: X4, x5: X5, cb: Callback<E, A>) => void
): (x1: X1, x2: X2, x3: X3, x4: X4, x5: X5) => Z.Effect<never, NonNullable<E>, A>

export function effectify<
X extends Fn,
F extends CustomPromisify<X>,
E2,
Cb = Last<Parameters<F>>,
E1 = Cb extends Function ? NonNullable<Parameters<Cb>[0]> : never
>(
fn: F,
mapError: (e: E1) => E2
): (
...args: F extends CustomPromisify<infer TCustom> ? Parameters<TCustom> : never[]
) => Z.Effect<
never,
E2,
F extends CustomPromisify<infer TCustom> ? UnwrapPromise<ReturnType<TCustom>> : never
>

export function effectify<E1, E2, A>(
fn: (cb: Callback<E1, A>) => void,
mapError: (e: NonNullable<E1>) => E2
): () => Z.Effect<never, E2, A>

export function effectify<E1, E2, A, X1>(
fn: (x1: X1, cb: Callback<E1, A>) => void,
mapError: (e: NonNullable<E1>) => E2
): (x1: X1) => Z.Effect<never, E2, A>

export function effectify<E1, E2, A, X1, X2>(
fn: (x1: X1, x2: X2, cb: Callback<E1, A>) => void,
mapError: (e: NonNullable<E1>) => E2
): (x1: X1, x2: X2) => Z.Effect<never, NonNullable<E2>, A>

export function effectify<E1, E2, A, X1, X2, X3>(
fn: (x1: X1, x2: X2, x3: X3, cb: Callback<E2, A>) => void,
mapError: (e: NonNullable<E1>) => E2
): (x1: X1, x2: X2, x3: X3) => Z.Effect<never, E2, A>

export function effectify<E1, E2, A, X1, X2, X3, X4>(
fn: (x1: X1, x2: X2, x3: X3, x4: X4, cb: Callback<E1, A>) => void,
mapError: (e: NonNullable<E1>) => E2
): (x1: X1, x2: X2, x3: X3, x4: X4) => Z.Effect<never, E2, A>

export function effectify<E1, E2, A, X1, X2, X3, X4, X5>(
fn: (x1: X1, x2: X2, x3: X3, x4: X4, x5: X5, cb: Callback<E1, A>) => void,
mapError: (e: NonNullable<E1>) => E2
): (x1: X1, x2: X2, x3: X3, x4: X4, x5: X5) => Z.Effect<never, E2, A>

/**
* Converts a callback-based async function into an `Effect`
*
* @param fn - the function to convert
* @param mapError - mapping function for the error (defaults to identity)
*/
export function effectify(fn: Function, mapError?: Function) {
return (...args: any[]) =>
Z.async<never, unknown, unknown>((resume) => {
fn(...args, (error: unknown, data: unknown) => {
if (error) {
resume(Z.fail(mapError ? mapError(error) : error))
} else {
resume(Z.succeed(data))
}
})
})
}
87 changes: 87 additions & 0 deletions test/effectify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as Effect from "@effect/io/Effect"
import * as Exit from "@effect/io/Exit"
import * as Option from "@fp-ts/data/Option"
import * as Cause from "@effect/io/Cause"
import * as it from "@effect/node/test/utils/extend"
import { assert, describe } from "vitest"
import { effectify } from "@effect/node/effectify"
import { pipe } from "@fp-ts/data/Function"
import fs from "node:fs"

export class TestError {
readonly _tag = "TestError"
constructor(readonly error: NodeJS.ErrnoException) {}
}

export const readFile1 = effectify(fs.readFile)
export const readFile2 = effectify(fs.readFile, (e) => new TestError(e))

describe.concurrent("effectify (readFile)", () => {
it.effect("handles happy path", () =>
Effect.gen(function* ($) {
const x = yield* $(readFile1(__filename))
assert.match(x.toString(), /^import/)
})
)

it.effect("preserves overloads (1)", () =>
Effect.gen(function* ($) {
const x = yield* $(readFile1(__filename, "utf8"))
assert.match(x.toString(), /^import/)
})
)

it.effect("preserves overloads (2)", () =>
Effect.gen(function* ($) {
const { signal } = new AbortController()
const x = yield* $(readFile1(__filename, { signal }))
assert.match(x.toString(), /^import/)
})
)

it.effect("handles error path", () =>
Effect.gen(function* ($) {
const result = yield* $(
pipe(
readFile1(__filename + "!@#%$"),
Effect.exit,
Effect.map(
Exit.match(
(cause) =>
pipe(
cause,
Cause.failureOption,
Option.map((x) => x.code)
),
() => Option.none
)
)
)
)
assert.deepStrictEqual(result, Option.some("ENOENT"))
})
)

it.effect("handles error path (with error mapping)", () =>
Effect.gen(function* ($) {
const result = yield* $(
pipe(
readFile2(__filename + "!@#%$"),
Effect.exit,
Effect.map(
Exit.match(
(cause) =>
pipe(
cause,
Cause.failureOption,
Option.map((x) => ({ tag: x._tag, code: x.error.code }))
),
() => Option.none
)
)
)
)
assert.deepStrictEqual(result, Option.some({ tag: "TestError", code: "ENOENT" }))
})
)
})
124 changes: 124 additions & 0 deletions test/utils/extend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as Effect from "@effect/io/Effect"
import * as TestEnvironment from "@effect/io/internal/testing/testEnvironment"
import * as Schedule from "@effect/io/Schedule"
import type * as Scope from "@effect/io/Scope"
import * as Duration from "@fp-ts/data/Duration"
import { pipe } from "@fp-ts/data/Function"
import type { TestAPI } from "vitest"
import * as V from "vitest"

export type API = TestAPI<{}>

export const it: API = V.it

export const effect = (() => {
const f = <E, A>(
name: string,
self: () => Effect.Effect<TestEnvironment.TestEnvironment, E, A>,
timeout = 5_000
) => {
return it(
name,
() =>
pipe(
Effect.suspendSucceed(self),
Effect.provideLayer(TestEnvironment.TestEnvironment),
Effect.unsafeRunPromise
),
timeout
)
}
return Object.assign(f, {
skip: <E, A>(
name: string,
self: () => Effect.Effect<TestEnvironment.TestEnvironment, E, A>,
timeout = 5_000
) => {
return it.skip(
name,
() =>
pipe(
Effect.suspendSucceed(self),
Effect.provideLayer(TestEnvironment.TestEnvironment),
Effect.unsafeRunPromise
),
timeout
)
},
only: <E, A>(
name: string,
self: () => Effect.Effect<TestEnvironment.TestEnvironment, E, A>,
timeout = 5_000
) => {
return it.only(
name,
() =>
pipe(
Effect.suspendSucceed(self),
Effect.provideLayer(TestEnvironment.TestEnvironment),
Effect.unsafeRunPromise
),
timeout
)
}
})
})()

export const live = <E, A>(
name: string,
self: () => Effect.Effect<never, E, A>,
timeout = 5_000
) => {
return it(
name,
() => pipe(Effect.suspendSucceed(self), Effect.unsafeRunPromise),
timeout
)
}

export const flakyTest = <R, E, A>(
self: Effect.Effect<R, E, A>,
timeout: Duration.Duration = Duration.seconds(30)
) => {
return pipe(
Effect.resurrect(self),
Effect.retry(
pipe(
Schedule.recurs(10),
Schedule.compose(Schedule.elapsed()),
Schedule.whileOutput(Duration.lessThanOrEqualTo(timeout))
)
),
Effect.orDie
)
}

export const scoped = <E, A>(
name: string,
self: () => Effect.Effect<Scope.Scope | TestEnvironment.TestEnvironment, E, A>,
timeout = 5_000
) => {
return it(
name,
() =>
pipe(
Effect.suspendSucceed(self),
Effect.scoped,
Effect.provideLayer(TestEnvironment.TestEnvironment),
Effect.unsafeRunPromise
),
timeout
)
}

export const scopedLive = <E, A>(
name: string,
self: () => Effect.Effect<Scope.Scope, E, A>,
timeout = 5_000
) => {
return it(
name,
() => pipe(Effect.suspendSucceed(self), Effect.scoped, Effect.unsafeRunPromise),
timeout
)
}

0 comments on commit f160a14

Please sign in to comment.