From 8e4e91c6d73486686771904d87b9feff3b922513 Mon Sep 17 00:00:00 2001 From: Pablo Baleztena Date: Fri, 1 Nov 2024 23:37:41 -0300 Subject: [PATCH] [fabric/core] (WIP) Add basic effect definition --- .../fabric/core/array/array-element.test.ts | 51 ++++++- packages/fabric/core/effect/effect.test.ts | 141 ++++++++++++++++++ packages/fabric/core/effect/effect.ts | 86 +++++++++++ packages/fabric/core/effect/index.ts | 1 + packages/fabric/core/types/merge-types.ts | 14 ++ 5 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 packages/fabric/core/effect/effect.test.ts create mode 100644 packages/fabric/core/effect/effect.ts create mode 100644 packages/fabric/core/effect/index.ts create mode 100644 packages/fabric/core/types/merge-types.ts diff --git a/packages/fabric/core/array/array-element.test.ts b/packages/fabric/core/array/array-element.test.ts index ef4fc1b..d9e0613 100644 --- a/packages/fabric/core/array/array-element.test.ts +++ b/packages/fabric/core/array/array-element.test.ts @@ -1,10 +1,57 @@ import { describe, expectTypeOf, test } from "@fabric/testing"; -import type { ArrayElement } from "./array-element.ts"; +import type { + ArrayElement, + TupleFirstElement, + TupleLastElement, +} from "./array-element.ts"; describe("ArrayElement", () => { test("Given an array, it should return the element type of the array", () => { type result = ArrayElement<["a", "b", "c"]>; - expectTypeOf().toEqualTypeOf<"a" | "b" | "c">(); }); + + test("Given an array of numbers, it should return the element type of the array", () => { + type result = ArrayElement<[1, 2, 3]>; + expectTypeOf().toEqualTypeOf<1 | 2 | 3>(); + }); + + test("Given an empty array, it should return never", () => { + type result = ArrayElement<[]>; + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe("TupleFirstElement", () => { + test("Given a tuple, it should return the first element type of the tuple", () => { + type result = TupleFirstElement<[1, 2, 3]>; + expectTypeOf().toEqualTypeOf<1>(); + }); + + test("Given a tuple with different types, it should return the first element type of the tuple", () => { + type result = TupleFirstElement<[string, number, boolean]>; + expectTypeOf().toEqualTypeOf(); + }); + + test("Given an empty tuple, it should return never", () => { + type result = TupleFirstElement<[]>; + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe("TupleLastElement", () => { + test("Given a tuple, it should return the last element type of the tuple", () => { + type result = TupleLastElement<[1, 2, 3]>; + expectTypeOf().toEqualTypeOf<3>(); + }); + + test("Given a tuple with different types, it should return the last element type of the tuple", () => { + type result = TupleLastElement<[string, number, boolean]>; + expectTypeOf().toEqualTypeOf(); + }); + + test("Given an empty tuple, it should return never", () => { + type result = TupleLastElement<[]>; + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/packages/fabric/core/effect/effect.test.ts b/packages/fabric/core/effect/effect.test.ts new file mode 100644 index 0000000..c66a3ef --- /dev/null +++ b/packages/fabric/core/effect/effect.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, expectTypeOf, fn, test } from "@fabric/testing"; +import { UnexpectedError } from "../index.ts"; +import { Result } from "../result/result.ts"; +import { Effect, type ExtractEffectDependencies } from "./effect.ts"; + +describe("Effect", () => { + test("Effect.from should create an Effect that returns a successful Result", async () => { + const effect = Effect.from(() => Result.ok(42)); + const result = await effect.run(); + expect(result.isOk()).toBe(true); + expect(result.unwrapOrThrow()).toBe(42); + }); + + test("Effect.from should create an Effect that returns a failed Result with the correct error type and message", async () => { + const effect = Effect.from(() => + Result.failWith(new UnexpectedError("failure")) + ); + const result = await effect.run(); + expect(result.isError()).toBe(true); + expect(result.unwrapErrorOrThrow()).toBeInstanceOf(UnexpectedError); + expect(result.unwrapErrorOrThrow().message).toBe("failure"); + }); + + test("Effect.tryFrom should create an Effect that returns a successful Result", async () => { + const effect = Effect.tryFrom( + () => Promise.resolve(42), + (e) => new UnexpectedError(e.message), + ); + const result = await effect.run(); + expect(result.isOk()).toBe(true); + expect(result.unwrapOrThrow()).toBe(42); + }); + + test("Effect.tryFrom should create an Effect that returns a failed Result with the correct error type and message", async () => { + const effect = Effect.tryFrom( + () => Promise.reject(new Error("failure")), + (e) => new UnexpectedError(e.message), + ); + const result = await effect.run(); + expect(result.isError()).toBe(true); + expect(result.unwrapErrorOrThrow()).toBeInstanceOf(UnexpectedError); + expect(result.unwrapErrorOrThrow().message).toBe("failure"); + }); + + test("Effect.ok should create an Effect that returns a successful Result", async () => { + const effect = Effect.ok(42); + const result = await effect.run(); + expect(result.isOk()).toBe(true); + expect(result.unwrapOrThrow()).toBe(42); + }); + + test("Effect.failWith should create an Effect that returns a failed Result with the correct error type and message", async () => { + const effect = Effect.failWith(new UnexpectedError("failure")); + const result = await effect.run(); + expect(result.isError()).toBe(true); + expect(result.unwrapErrorOrThrow()).toBeInstanceOf(UnexpectedError); + expect(result.unwrapErrorOrThrow().message).toBe("failure"); + }); + + test("Effect.map should map a successful Result to a new successful Result", async () => { + const effect = Effect.ok(42).map((value) => value * 2); + const result = await effect.run(); + expect(result.isOk()).toBe(true); + expect(result.unwrapOrThrow()).toBe(84); + }); + + test("Effect.map should skip maps when the Result is an error", async () => { + const mockFn = fn() as () => number; + const effect = Effect.failWith(new UnexpectedError("failure")).map( + mockFn, + ); + const result = await effect.run(); + expect(result.isError()).toBe(true); + expect(result.unwrapErrorOrThrow()).toBeInstanceOf(UnexpectedError); + expect(result.unwrapErrorOrThrow().message).toBe("failure"); + expect(mockFn).not.toHaveBeenCalled(); + }); + + test( + "Effect.flatMap should map a successful Effect to a new Effect", + async () => { + const effect = Effect.ok(42).flatMap((value) => Effect.ok(value * 2)); + const result = await effect.run(); + expect(result.isOk()).toBe(true); + expect(result.unwrapOrThrow()).toBe(84); + }, + ); + + test("Effect.flatMap should map a successful Effect to a new failed Effect", async () => { + const effect = Effect.ok(42).flatMap(() => + Effect.failWith(new UnexpectedError("failure")) + ); + const result = await effect.run(); + expect(result.isError()).toBe(true); + expect(result.unwrapErrorOrThrow()).toBeInstanceOf(UnexpectedError); + expect(result.unwrapErrorOrThrow().message).toBe("failure"); + }); + + test("Effect.flatMap should skip maps when the Result is an error", async () => { + const mockFn = fn() as () => Effect; + const effect = Effect.failWith(new UnexpectedError("failure")).flatMap( + mockFn, + ); + const result = await effect.run(); + expect(result.isError()).toBe(true); + expect(result.unwrapErrorOrThrow()).toBeInstanceOf(UnexpectedError); + expect(result.unwrapErrorOrThrow().message).toBe("failure"); + expect(mockFn).not.toHaveBeenCalled(); + }); + + test("Effect.flatMap should result in an effect which requires both dependencies", async () => { + const effect = Effect.from(({ a }: { a: number }) => Result.ok(a * 2)) + .flatMap((value) => + Effect.from(({ b }: { b: number }) => Result.ok(value + b)) + ); + + const result = await effect.run({ a: 1, b: 2 }); + expect(result.isOk()).toBe(true); + expect(result.unwrapOrThrow()).toBe(4); + + //This should fail to compile + //await effect.run({ a: 1 }); + + type Deps = ExtractEffectDependencies; + + expectTypeOf().toEqualTypeOf<{ a: number } & { b: number }>(); + }); + + test("Effect.flatMap should work if an effect has dependencies and the other effect does not", async () => { + const effect = Effect.from(({ a }: { a: number }) => Result.ok(a * 2)) + .flatMap((value) => Effect.ok(value + 2)); + + const result = await effect.run({ a: 1 }); + expect(result.isOk()).toBe(true); + expect(result.unwrapOrThrow()).toBe(4); + + type Deps = ExtractEffectDependencies; + + expectTypeOf().toEqualTypeOf<{ a: number }>(); + }); +}); diff --git a/packages/fabric/core/effect/effect.ts b/packages/fabric/core/effect/effect.ts new file mode 100644 index 0000000..72d031b --- /dev/null +++ b/packages/fabric/core/effect/effect.ts @@ -0,0 +1,86 @@ +// deno-lint-ignore-file no-explicit-any +import type { TaggedError } from "../error/tagged-error.ts"; +import { Result } from "../result/result.ts"; +import type { MaybePromise } from "../types/maybe-promise.ts"; +import type { MergeTypes } from "../types/merge-types.ts"; + +export class Effect< + TDeps = void, + TValue = any, + TError extends TaggedError = never, +> { + static from( + fn: (deps: TDeps) => MaybePromise>, + ): Effect { + return new Effect(fn); + } + static tryFrom( + fn: () => MaybePromise, + errorMapper: (error: any) => TError, + ): Effect { + return new Effect( + async () => { + try { + const value = await fn(); + return Result.ok(value); + } catch (error) { + return Result.failWith(errorMapper(error)); + } + }, + ); + } + + static ok(value: TValue): Effect { + return new Effect(() => Result.ok(value)); + } + + static failWith( + error: TError, + ): Effect { + return new Effect(() => Result.failWith(error)); + } + + constructor( + private readonly fn: ( + deps: TDeps, + ) => MaybePromise>, + ) { + } + + map( + fn: (value: TValue) => MaybePromise, + ): Effect { + return new Effect(async (deps: TDeps) => { + const result = await this.fn(deps); + if (result.isError()) { + return result; + } + return Result.ok(await fn(result.value as TValue)); + }); + } + + flatMap( + fn: ( + value: TValue, + ) => Effect, + ): Effect, TNewValue, TError | TNewError> { + return new Effect(async (deps: TDeps & TNewDeps) => { + const result = await this.fn(deps); + if (result.isError()) { + return result as Result; + } + return await fn(result.value as TValue).fn(deps); + }) as Effect< + MergeTypes, + TNewValue, + TError | TNewError + >; + } + + async run(deps: TDeps): Promise> { + return await this.fn(deps); + } +} + +export type ExtractEffectDependencies = T extends + Effect ? TDeps : never; diff --git a/packages/fabric/core/effect/index.ts b/packages/fabric/core/effect/index.ts new file mode 100644 index 0000000..150444d --- /dev/null +++ b/packages/fabric/core/effect/index.ts @@ -0,0 +1 @@ +export * from "./effect.ts"; diff --git a/packages/fabric/core/types/merge-types.ts b/packages/fabric/core/types/merge-types.ts new file mode 100644 index 0000000..451863f --- /dev/null +++ b/packages/fabric/core/types/merge-types.ts @@ -0,0 +1,14 @@ +/** + * Merges two types A and B into a new type that contains all the properties of A and B. + * This is like `A & B`, but it also works when A or B are `never` or `void` and coalesces the result type + * to the non-never type. + * @example + * type AuB = MergeTypes<{ a: string }, { b: number }>; //{ a: string, b: number } + * const x: AuB = { a: "a", b: 1 }; + * + * type JustB = MergeTypes; //{ b: number } + * const y: JustB = { b: 1 }; + */ +export type MergeTypes = [A] extends [void] | [never] ? B + : [B] extends [void] | [never] ? A + : A & B;