Skip to content

Commit

Permalink
feat: Add Ether and EtherT (#166)
Browse files Browse the repository at this point in the history
  • Loading branch information
MikuroXina authored Mar 28, 2024
1 parent 748ec93 commit 1b8e93b
Show file tree
Hide file tree
Showing 4 changed files with 325 additions and 0 deletions.
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * as MonadCont from "./src/cont/monad.ts";
export * as Coyoneda from "./src/coyoneda.ts";
export * as Curry from "./src/curry.ts";
export * as Dual from "./src/dual.ts";
export * as Ether from "./src/ether.ts";
export * as Free from "./src/free.ts";
export * as MonadFree from "./src/free/monad.ts";
export * as Frozen from "./src/frozen.ts";
Expand Down
61 changes: 61 additions & 0 deletions src/ether.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { assertEquals } from "../deps.ts";
import { compose, newEther, newEtherSymbol, runEther } from "./ether.ts";

type Article = {
createdAt: string;
updatedAt: string;
body: string;
};

interface ArticleRepository {
has: (id: string) => Promise<boolean>;
insert: (id: string, article: Partial<Article>) => Promise<void>;
}
const repoSymbol = newEtherSymbol<ArticleRepository>();

type Req = {
id: string;
timestamp: string;
body: string;
};
const serviceSymbol = newEtherSymbol<(req: Req) => Promise<void>>();
const service = newEther(
serviceSymbol,
({ repo }) => async ({ id, timestamp, body }: Req) => {
if (!await repo.has(id)) {
return;
}
await repo.insert(id, { updatedAt: timestamp, body });
return;
},
{
repo: repoSymbol,
},
);

Deno.test("runs an Ether", async () => {
const mockRepository = newEther(
repoSymbol,
() => ({
has: (id) => {
assertEquals(id, "foo");
return Promise.resolve(true);
},
insert: (id, article) => {
assertEquals(id, "foo");
assertEquals(article, {
updatedAt: "2020-01-01T13:17:00Z",
body: "Hello, World!",
});
return Promise.resolve();
},
}),
);
const injecting = compose(mockRepository);
const ether = injecting(service);
await runEther(ether)({
id: "foo",
timestamp: "2020-01-01T13:17:00Z",
body: "Hello, World!",
});
});
192 changes: 192 additions & 0 deletions src/ether.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* This package provides a dependency container combinator `EtherT`/`Ether` and associated functions.
*
* @packageDocumentation
*/

import type { Get1, Hkt2, Hkt3 } from "./hkt.ts";
import type { Monad } from "./type-class/monad.ts";
import { IdentityHkt } from "./identity.ts";
import { doT } from "./cat.ts";

/**
* A symbol among Ether values, which resolves into the actual producing type.
*/
export type EtherSymbol<T> = symbol & {
readonly etherValue: T;
};
/**
* Gets the value type from an `EtherSymbol`.
*/
export type EtherValue<S> = S extends EtherSymbol<infer T> ? T : never;

/**
* Makes a new `EtherSymbol` for `newEther` or `newEtherT`. It should be created with an interface definition.
*
* @returns The unique `EtherSymbol`.
*/
export const newEtherSymbol = <T>(): EtherSymbol<T> =>
Symbol() as EtherSymbol<T>;

/**
* A dependencies object which will be passed to `Ether`'s handler.
*/
export type EtherDeps<D> = {
[K in keyof D]: EtherValue<D[K]>;
};

/**
* Omits fields of type `T` from a dependencies object type `D`.
*/
export type OmitType<D, T> = {
[K in keyof D as D[K] extends EtherSymbol<T> ? never : K]: D[K];
};

/**
* EtherT is a function which needs dependencies `D` and returns `M<T>`.
*/
export interface EtherT<D, M, T> {
readonly selfSymbol: EtherSymbol<T>;
readonly handler: (resolved: EtherDeps<D>) => Get1<M, T>;
readonly depSymbols: D;
}

/**
* Executes an `EtherT` which requires no dependencies.
*
* @param ether Execution target.
* @returns The return value of `ether`'s handler.
*/
export const runEtherT = <M, T>(
ether: EtherT<Record<string, never>, M, T>,
): Get1<M, T> => ether.handler({});

/**
* Creates a new `EtherT`.
*
* @param selfSymbol The unique symbol corresponding to the return type `T`.
* @param handler The function which creates an object of type `T` from the dependencies object on `M`.
* @param depSymbols The declaration of symbols by key, for `handler` which requires dependencies.
* @returns A new `EtherT` having parameters as fields.
*/
export const newEtherT = <M>() =>
<
T,
const D extends Record<string, symbol> = Record<string, never>,
>(
selfSymbol: EtherSymbol<T>,
handler: (deps: EtherDeps<D>) => Get1<M, T>,
depSymbols: D = {} as D,
): EtherT<D, M, T> => ({ selfSymbol, handler, depSymbols });

/**
* Composes two `EtherT`s into a new one. Injects `lower` into `upper`.
*
* @param monad The monad implementation for `M`.
* @param lower The lower dependency, which will be injected, such as a database adaptor.
* @param upper The upper dependency, which will injected `lower`, such as a service function.
* @returns The injected `EtherT`, which will be resolved the matching dependencies on `upper` with `lower`'s handler.
*/
export const composeT =
<M>(monad: Monad<M>) =>
<const D extends Record<string, symbol>, T>(lower: EtherT<D, M, T>) =>
<const E extends Record<string, symbol>, U>(
upper: EtherT<E, M, U>,
): EtherT<OmitType<E, T> & D, M, U> => {
const targetKeys = Object.entries(upper.depSymbols).filter(([, sym]) =>
sym === lower.selfSymbol
).map(([key]) => key);
return {
selfSymbol: upper.selfSymbol,
handler: (deps) =>
doT(monad).addM("resolved", lower.handler(deps))
.addMWith("ret", ({ resolved }) => {
const depsForUpper: Record<string, unknown> = {
...deps,
};
for (const targetKey of targetKeys) {
depsForUpper[targetKey] = resolved;
}
return upper.handler(depsForUpper as EtherDeps<E>);
}).finish(({ ret }) => ret),
depSymbols: Object.fromEntries(
Object.entries({ ...upper.depSymbols, ...lower.depSymbols })
.filter(
([, depSym]) => depSym === lower.selfSymbol,
),
) as OmitType<E, T> & D,
};
};

export interface EtherTHkt extends Hkt3 {
readonly type: EtherT<this["arg3"], this["arg2"], this["arg1"]>;
}

/**
* Ether is a function which needs dependencies `D` and returns `M<T>`.
*/
export type Ether<D, T> = EtherT<D, IdentityHkt, T>;

/**
* Executes an `Ether` which requires no dependencies.
*
* @param ether Execution target.
* @returns The return value of `ether`'s handler.
*/
export const runEther = <T>(ether: Ether<Record<string, never>, T>): T =>
ether.handler({});

/**
* Creates a new `Ether`.
*
* @param selfSymbol The unique symbol corresponding to the return type `T`.
* @param handler The function which creates an object of type `T` from the dependencies object.
* @param depSymbols The declaration of symbols by key, for `handler` which requires dependencies.
* @returns A new `Ether` having parameters as fields.
*/
export const newEther = <
T,
const D extends Record<string, symbol> = Record<string, never>,
>(
selfSymbol: EtherSymbol<T>,
handler: (deps: EtherDeps<D>) => T,
depSymbols: D = {} as D,
): Ether<D, T> => ({ selfSymbol, handler, depSymbols });

/**
* Composes two `Ether`s into a new one. Injects `lower` into `upper`.
*
* @param lower The lower dependency, which will be injected, such as a database adaptor.
* @param upper The upper dependency, which will injected `lower`, such as a service function.
* @returns The injected `Ether`, which will be resolved the matching dependencies on `upper` with `lower`'s handler.
*/
export const compose =
<const D extends Record<string, symbol>, T>(lower: Ether<D, T>) =>
<const E extends Record<string, symbol>, U>(
upper: Ether<E, U>,
): Ether<OmitType<E, T> & D, U> => {
const targetKeys = Object.entries(upper.depSymbols).filter(([, sym]) =>
sym === lower.selfSymbol
).map(([key]) => key);
return {
selfSymbol: upper.selfSymbol,
handler: (deps) => {
const resolved = lower.handler(deps);
const depsForUpper: Record<string, unknown> = { ...deps };
for (const targetKey of targetKeys) {
depsForUpper[targetKey] = resolved;
}
return upper.handler(depsForUpper as EtherDeps<E>);
},
depSymbols: Object.fromEntries(
Object.entries({ ...upper.depSymbols, ...lower.depSymbols })
.filter(
([, depSym]) => depSym === lower.selfSymbol,
),
) as OmitType<E, T> & D,
};
};

export interface EtherHkt extends Hkt2 {
readonly type: Ether<this["arg2"], this["arg1"]>;
}
71 changes: 71 additions & 0 deletions src/reader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { assertEquals } from "../deps.ts";
import { catT } from "./cat.ts";
import { mapOr, none, Option, some } from "./option.ts";
import { local } from "./reader.ts";
import { run } from "./reader.ts";
import { ask } from "./reader.ts";
import { monad } from "./reader.ts";
import { Reader } from "./reader.ts";

Deno.test("ask", () => {
interface User {
name: string;
}
const userCat = catT(monad<User>());

const message = (): Reader<User, string> =>
userCat(ask<User>()).finish(
({ name }) => `Hello, ${name}!`,
);
const box = (): Reader<User, string> =>
userCat(message()).finish(
(mes) => `<div class="message-box">${mes}</div>`,
);

assertEquals(
run(box())({ name: "John" }),
'<div class="message-box">Hello, John!</div>',
);
assertEquals(
run(box())({ name: "Alice" }),
'<div class="message-box">Hello, Alice!</div>',
);
});

Deno.test("local", () => {
interface User {
name: string;
id: string;
score: number;
}
interface Bulk {
users: readonly User[];
}

const extractFromBulk = (id: string) =>
local((bulk: Bulk): Option<User> => {
const found = bulk.users.find((elem) => elem.id === id);
if (!found) {
return none();
}
return some(found);
});
const scoreReport = (id: string): Reader<Bulk, string> =>
extractFromBulk(id)(
catT(monad<Option<User>>())(ask<Option<User>>())
.finish(
mapOr("user not found")(({ name, score }) =>
`${name}'s score is ${score}!`
),
),
);

const bulk: Bulk = {
users: [
{ name: "John", id: "1321", score: 12130 },
{ name: "Alice", id: "4209", score: 320123 },
],
};
assertEquals(run(scoreReport("1321"))(bulk), "John's score is 12130!");
assertEquals(run(scoreReport("4209"))(bulk), "Alice's score is 320123!");
});

0 comments on commit 1b8e93b

Please sign in to comment.