[fabric/core] (WIP) Add basic effect definition
This commit is contained in:
parent
4cc3324b46
commit
8e4e91c6d7
@ -1,10 +1,57 @@
|
|||||||
import { describe, expectTypeOf, test } from "@fabric/testing";
|
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", () => {
|
describe("ArrayElement", () => {
|
||||||
test("Given an array, it should return the element type of the array", () => {
|
test("Given an array, it should return the element type of the array", () => {
|
||||||
type result = ArrayElement<["a", "b", "c"]>;
|
type result = ArrayElement<["a", "b", "c"]>;
|
||||||
|
|
||||||
expectTypeOf<result>().toEqualTypeOf<"a" | "b" | "c">();
|
expectTypeOf<result>().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<result>().toEqualTypeOf<1 | 2 | 3>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Given an empty array, it should return never", () => {
|
||||||
|
type result = ArrayElement<[]>;
|
||||||
|
expectTypeOf<result>().toEqualTypeOf<never>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TupleFirstElement", () => {
|
||||||
|
test("Given a tuple, it should return the first element type of the tuple", () => {
|
||||||
|
type result = TupleFirstElement<[1, 2, 3]>;
|
||||||
|
expectTypeOf<result>().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<result>().toEqualTypeOf<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Given an empty tuple, it should return never", () => {
|
||||||
|
type result = TupleFirstElement<[]>;
|
||||||
|
expectTypeOf<result>().toEqualTypeOf<never>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TupleLastElement", () => {
|
||||||
|
test("Given a tuple, it should return the last element type of the tuple", () => {
|
||||||
|
type result = TupleLastElement<[1, 2, 3]>;
|
||||||
|
expectTypeOf<result>().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<result>().toEqualTypeOf<boolean>();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Given an empty tuple, it should return never", () => {
|
||||||
|
type result = TupleLastElement<[]>;
|
||||||
|
expectTypeOf<result>().toEqualTypeOf<never>();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
141
packages/fabric/core/effect/effect.test.ts
Normal file
141
packages/fabric/core/effect/effect.test.ts
Normal file
@ -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<void, number, UnexpectedError>;
|
||||||
|
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<typeof effect>;
|
||||||
|
|
||||||
|
expectTypeOf<Deps>().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<typeof effect>;
|
||||||
|
|
||||||
|
expectTypeOf<Deps>().toEqualTypeOf<{ a: number }>();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
packages/fabric/core/effect/effect.ts
Normal file
86
packages/fabric/core/effect/effect.ts
Normal file
@ -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<TValue, TError extends TaggedError = never, TDeps = void>(
|
||||||
|
fn: (deps: TDeps) => MaybePromise<Result<TValue, TError>>,
|
||||||
|
): Effect<TDeps, TValue, TError> {
|
||||||
|
return new Effect(fn);
|
||||||
|
}
|
||||||
|
static tryFrom<TValue, TError extends TaggedError = never, TDeps = void>(
|
||||||
|
fn: () => MaybePromise<TValue>,
|
||||||
|
errorMapper: (error: any) => TError,
|
||||||
|
): Effect<TDeps, TValue, TError> {
|
||||||
|
return new Effect(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const value = await fn();
|
||||||
|
return Result.ok(value);
|
||||||
|
} catch (error) {
|
||||||
|
return Result.failWith(errorMapper(error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ok<TValue>(value: TValue): Effect<void, TValue, never> {
|
||||||
|
return new Effect(() => Result.ok(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static failWith<TError extends TaggedError>(
|
||||||
|
error: TError,
|
||||||
|
): Effect<void, never, TError> {
|
||||||
|
return new Effect(() => Result.failWith(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fn: (
|
||||||
|
deps: TDeps,
|
||||||
|
) => MaybePromise<Result<TValue, TError>>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
map<TNewValue>(
|
||||||
|
fn: (value: TValue) => MaybePromise<TNewValue>,
|
||||||
|
): Effect<TDeps, TNewValue, TError> {
|
||||||
|
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<TNewValue, TNewError extends TaggedError, TNewDeps = void>(
|
||||||
|
fn: (
|
||||||
|
value: TValue,
|
||||||
|
) => Effect<TNewDeps, TNewValue, TNewError>,
|
||||||
|
): Effect<MergeTypes<TDeps, TNewDeps>, TNewValue, TError | TNewError> {
|
||||||
|
return new Effect(async (deps: TDeps & TNewDeps) => {
|
||||||
|
const result = await this.fn(deps);
|
||||||
|
if (result.isError()) {
|
||||||
|
return result as Result<TNewValue, TError | TNewError>;
|
||||||
|
}
|
||||||
|
return await fn(result.value as TValue).fn(deps);
|
||||||
|
}) as Effect<
|
||||||
|
MergeTypes<TDeps, TNewDeps>,
|
||||||
|
TNewValue,
|
||||||
|
TError | TNewError
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(deps: TDeps): Promise<Result<TValue, TError>> {
|
||||||
|
return await this.fn(deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtractEffectDependencies<T> = T extends
|
||||||
|
Effect<infer TDeps, any, any> ? TDeps : never;
|
||||||
1
packages/fabric/core/effect/index.ts
Normal file
1
packages/fabric/core/effect/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./effect.ts";
|
||||||
14
packages/fabric/core/types/merge-types.ts
Normal file
14
packages/fabric/core/types/merge-types.ts
Normal file
@ -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<never, { b: number }>; //{ b: number }
|
||||||
|
* const y: JustB = { b: 1 };
|
||||||
|
*/
|
||||||
|
export type MergeTypes<A, B> = [A] extends [void] | [never] ? B
|
||||||
|
: [B] extends [void] | [never] ? A
|
||||||
|
: A & B;
|
||||||
Loading…
Reference in New Issue
Block a user