flue-ts
v0.0.10
Published
A Flue represents a lazy async computation that depends on a value.
Downloads
4,332
Readme
Flue
A Flue represents a lazy async computation that depends on a value.
To create and execute Flues, start by importing Flue.
import { Flue } from "flue-ts";
Table of contents
Execution
Call execute
or toEither
with the dependency as argument to execute a Flue.
Execute
Returns a promise that resolves with the Flue value or rejects.
const emptyDependency = undefined;
await Flue.resolve(1).execute(emptyDependency);
1
ToEither
Returns an object that represents either a success (Right) or a failure (Left).
await Flue.resolve(2).toEither(emptyDependency);
{
"_tag": "Right",
"right": 2,
}
await Flue.reject(new Error("It failed")).toEither(
emptyDependency,
);
{
"_tag": "Left",
"left": [Error: It failed],
}
Creation
Resolve
Flue.resolve creates a Flue that always resolves the passed value.
await Flue.resolve(1).execute(emptyDependency);
1
Reject
Flue.rejects creates a Flue that always rejects with the passed value.
await Flue.reject(new Error("It failed")).toEither(
emptyDependency,
);
{
"_tag": "Left",
"left": [Error: It failed],
}
Try
Accepts an async function that receives the dependencies, and may fail.
The Flue either resolves if the callback resolves, or fails if the callback rejects or throws.
await Flue.try(async (d) => d).execute({
myDependency: "value",
});
{
"myDependency": "value",
}
await Flue.try(() => {
throw new Error("My message");
}).toEither(emptyDependency);
{
"_tag": "Left",
"left": [Error: My message],
}
await Flue.try(() => {
return Promise.reject(Error("My other message"));
}).toEither(emptyDependency);
{
"_tag": "Left",
"left": [Error: My other message],
}
Dependencies
Services are used to represent the dependencies of a Flue.
As an example, let's create a Service that provides us with an increasing unique integer.
import { Service } from "flue-ts";
type IncreasingIdService = {
getId: () => number;
};
const IncreasingIdService = Service<IncreasingIdService>();
Flues can declare its dependencies by calling .depends:
const MyFlue = Flue.depends(IncreasingIdService);
The MyFlue constructor works like the Flue constructor:
const getId = MyFlue.try((d) => d.getId());
We must provide the dependencies when executing the Flue:
let count = 0;
const increasingIdService = {
getId: () => count++,
};
await getId.execute(increasingIdService);
0
Notice that unlike a Promise, Flues are lazy. Executing them will re-execute all the previous steps.
await getId.execute(increasingIdService);
1
Composition
Flue is better used if you're not calling execute
directly, but rather composing smaller Flues.
All composition methods on Flue are immutable. Flue supports a Monadic Composition API, and more:
Try
Try is similar to Promise.then, or a .map
of a Monad.
The callback receives the result of the previous Flue, if the previous Flue was successful. The callback can be sync, async, might throw or reject.
If the previous Flue failed, the .try
call is ignored.
For example, a sync callback:
await getId
.try((id) => `ID: ${id}`)
.execute(increasingIdService);
"ID: 2"
An async callback:
await getId
.try(async (id) => await Promise.resolve(`ID: ${id}`))
.execute(increasingIdService);
"ID: 3"
If an error is thrown or the returned promise rejects, the Flue fails.
await getId
.try((id) => {
throw new Error("Error with id: " + id);
})
.toEither(increasingIdService);
{
"_tag": "Left",
"left": [Error: Error with id: 4],
}
await getId
.try(async (id) => {
throw new Error("Error with id: " + id);
})
.toEither(increasingIdService);
{
"_tag": "Left",
"left": [Error: Error with id: 5],
}
await getId
.try((id) => {
return Promise.reject(
new Error("Error with id: " + id),
);
})
.toEither(increasingIdService);
{
"_tag": "Left",
"left": [Error: Error with id: 6],
}
FlatMap
FlatMap allows you to "join" two Flues together into one. It "transforms" a Flue that returns a Flue into just one Flue.
It is similar to Promise.then, but Flues will not flatten automatically. It is also known as Monad.chain
.
The callback receives the result of the previous Flue, if the previous Flue was successful. The callback must synchronously return a Flue.
If the previous Flue failed, the .flatMap
call is ignored.
await getId
.flatMap((id) => MyFlue.resolve(`ID: ${id}`))
.execute(increasingIdService);
"ID: 7"
await getId
.flatMap((first) =>
getId.try(
(second) => `First: ${first} - Second: ${second} `,
),
)
.execute(increasingIdService);
"First: 8 - Second: 9 "
AddKv
As can be seen from the previous FlatMap example, using just .try
and .flatMap
to create bindings
is not a good experience - creating bindings require deeply nested callbacks.
The solution is to use .addKv
to create the bindings. Start with an empty object and add properties to it.
The callback receives the current object, and the value the callback returns is added to the object.
await MyFlue.resolve({})
.addKv("first", () => getId)
.addKv("second", () => getId)
.addKv("message", (accumulator) =>
Flue.resolve(
`First: ${accumulator.first} - Second: ${accumulator.second}`,
),
)
.execute(increasingIdService);
{
"first": 10,
"message": "First: 10 - Second: 11",
"second": 11,
}
TryKv
In the last example, we had to create a Flue just to wrap the message, as .addKv
requires the returned values to be a Flue.
We could use .tryKv
instead:
await MyFlue.resolve({})
.addKv("first", () => getId)
.addKv("second", () => getId)
.tryKv(
"message",
(acc) => `First: ${acc.first} - Second: ${acc.second}`,
)
.execute(increasingIdService);
{
"first": 12,
"message": "First: 12 - Second: 13",
"second": 13,
}
Tap
Tap allows you to "tap" into a Flue, read it's value and return a Flue, like .flatMap
.
If the value returned by the .tap
callback is successful it will be ignored.
Tap is useful for logging. We can define a LoggerService, and a log
Flue that uses it.
type LoggerService = {
log: (message: string) => void;
};
const LoggerService = Service<LoggerService>();
const log = (message: string) =>
Flue.depends(LoggerService).try((d) => d.log(message));
We must implement the LoggerService:
let logs: string[] = [];
const loggerService = {
log: (it: any) => {
logs.push(String(it));
},
};
In the following example .tap
logs the generated ID but does not change the value of the Flue.
logs = [];
await getId
.tap((id) => log(`generated ID: ${id}`))
.try((id) => ({ id }))
.execute({ ...increasingIdService, ...loggerService });
{
"id": 14,
}
logs;
["generated ID: 14"];
If .tap
fails, the Flue fails.
await getId
.tap((id) => {
throw new Error("Failure with id: " + id);
})
.toEither(increasingIdService);
{
"_tag": "Left",
"left": [Error: Failure with id: 15],
}
TryEither
Try either is similar to .try
, but the returned Either signifies a success or failure of the Flue.
If the returned value is Right
, the Flue succeeds:
await getId
.tryEither((id) => ({
_tag: "Right",
right: `ID: ${id}`,
}))
.execute(increasingIdService);
"ID: 16"
If the returned value is Left
, the Flue fails:
await getId
.tryEither((id) => ({
_tag: "Left",
left: `ID: ${id}`,
}))
.toEither(increasingIdService);
{
"_tag": "Left",
"left": "ID: 17",
}
Collections
All
Flue.all
is similar to Promise.all
. Pass it an array of Flues, and receive back a Flue of the array.
The Flues will be executed in parallel.
await Flue.all([Flue.resolve(1), Flue.resolve(2)]).execute(
emptyDependency,
);
[1, 2];
Sequence
Flue.sequence
is similar to Flue.all
. Pass it an array of Flues, and receive back a Flue of the array.
The Flues will be executed in sequence/series.
await Flue.sequence([
Flue.resolve(1),
Flue.resolve(2),
]).execute(emptyDependency);
[1, 2];
Error Handling
Flue exceptions, as in Typescript and Javascript, have no types or restraints. Anything can be thrown, and a Flue can fail with any value.
As an example, we can fail with just the number 1
:
await Flue.reject(1).toEither(emptyDependency);
{
"_tag": "Left",
"left": 1,
}
Flue supports many mechanism to handle errors:
Finally
Finally runs after the Flue, if it has failed or not. Finally does not receive any information on whether the Flue failed or succeeded. It is useful for logging, benchmarking and closing/deleting resources.
As an example, we can use it to benchmark a Flue. We can define a ClockService.
type ClockService = {
now: () => Date;
};
const ClockService = Service<ClockService>();
Using the clock dependency, we can create a Flue that returns the current time.
const getNow = Flue.depends(ClockService).try((d) =>
d.now(),
);
We must implement the ClockService:
const clockService = {
now: () => new Date("2023-05-27T05:15:46.577Z"),
};
We can now use ClockService in .finally
. Notice .finally
callback returns a Flue, like .flatMap
.
logs = [];
await Flue.reject(new Error("There was an error"))
.finally(() =>
getNow.flatMap((now) =>
log("Finished at: " + now.toUTCString()),
),
)
.toEither({ ...loggerService, ...clockService });
{
"_tag": "Left",
"left": [Error: There was an error],
}
logs;
["Finished at: Sat, 27 May 2023 05:15:46 GMT"];
TransformError
.transformError
transforms the error into another error contained in the Flue returned by the callback.
Useful to consume errors by logging them and hiding them from the caller:
logs = [];
await Flue.reject(new Error("The error!!!"))
.transformError((e) =>
log(String(e)).try(() => "Internal error"),
)
.toEither(loggerService);
{
"_tag": "Left",
"left": "Internal error",
}
logs;
["Error: The error!!!"];
Fold
Allows a failed Flue to return to a succeeded state.
We must pass it two functions, the first executes if the Flue failed, the second executes if the Flue succeeded.
If the previous Flue succeeded, the second callback is called.
await Flue.resolve(1)
.fold(
(e) => Flue.resolve("Error getting count: " + e),
(c) => Flue.resolve("Count: " + c),
)
.execute(emptyDependency);
"Count: 1"
If the previous Flue failed, the first callback is called.
await Flue.reject(new Error("404: not found"))
.fold(
(e) => Flue.resolve("Error getting count: " + e),
(c) => Flue.resolve("Count: " + c),
)
.toEither(emptyDependency);
{
"_tag": "Right",
"right": "Error getting count: Error: 404: not found",
}
If the fold callbacks returns a failed Flue, the outer Flue fails, too.
await Flue.resolve(1)
.fold(
(e) => Flue.reject("Error getting count: " + e),
(c) => Flue.reject("Error with count: " + c),
)
.toEither(emptyDependency);
{
"_tag": "Left",
"left": "Error with count: 1",
}
Fix
Allows a failed Flue to return to a succeeded state.
await Flue.reject(1)
.fix((e) => {
throw e;
})
.toEither(emptyDependency);
{
"_tag": "Left",
"left": 1,
}
await Flue.reject<unknown, number>(1)
.fix(() => 2)
.toEither(emptyDependency);
{
"_tag": "Right",
"right": 2,
}
Composition & Dependencies
All composition methods provide the dependencies as the second parameter. It can be used to write very concise code.
Use it in combination with the .depends
method:
logs = [];
await getId
.depends(ClockService)
.flatMap((id, deps) =>
log(`[${deps.now().toUTCString()}] Got id: ${id}`),
)
.try((_void, deps) => deps.getId())
.tap((id, deps) =>
log(`[${deps.now().toUTCString()}] Got id: ${id}`),
)
.execute({
...clockService,
...loggerService,
...increasingIdService,
});
19
logs;
[
"[Sat, 27 May 2023 05:15:46 GMT] Got id: 18",
"[Sat, 27 May 2023 05:15:46 GMT] Got id: 19",
];
Do
Allows extension of Flues. As an example, let's build a simple mechanism to retry Flues:
Retry N can be configured to retry a Flue N times. It uses the LoggerService and the ClockService. A Flue that is retried will maintain the same return type, but it's dependencies will be merged to Logger and Clock.
const retryN =
(n: number) =>
<D, A>(
it: Flue<D, A>,
): Flue<D & LoggerService & ClockService, A> =>
Flue.try(async (d) => {
let tries = n;
while (tries >= 0) {
tries--;
try {
return await it.execute(d);
} catch (e) {
const now = d.now().toUTCString();
d.log(`[${now}] Error: ${e}`);
if (tries <= 0) {
throw e;
}
}
}
throw "impossible";
});
We can create a function that makes a Flue retry twice:
const retryTwice = retryN(2);
Without Do
we can use by wrapping a Flue.
logs = [];
await retryTwice(
Flue.try(() => 1)
.try((it) => it + 1)
.try((v) => {
throw v;
}),
)
.try((it) => it + 1)
.try((it) => it + 1)
.toEither({
...loggerService,
...clockService,
});
{
"_tag": "Left",
"left": 2,
}
logs;
[
"[Sat, 27 May 2023 05:15:46 GMT] Error: 2",
"[Sat, 27 May 2023 05:15:46 GMT] Error: 2",
];
Using Do, we don't need to stop the method chain.
logs = [];
await Flue.try(() => 1)
.try((it) => it + 1)
.try((v) => {
throw v;
})
.do(retryTwice)
.try((it) => it + 1)
.try((it) => it + 1)
.toEither({
...loggerService,
...clockService,
});
{
"_tag": "Left",
"left": 2,
}
logs;
[
"[Sat, 27 May 2023 05:15:46 GMT] Error: 2",
"[Sat, 27 May 2023 05:15:46 GMT] Error: 2",
];
Dependencies, Creation & Composition
Flues can be built by calling the static methods of the Flue class:
await Flue.resolve(1).execute(null);
1
Or Flues can be built by calling the same methods on a Builder that pre-defines the dependencies:
Creating
We can create a FlueBuilder with pre-defined dependencies:
const LoggingFlue = Flue.depends(LoggerService);
FlueBuilder supports resolve
:
await LoggingFlue.resolve(1).execute(loggerService);
1
FlueBuilder supports reject
:
await LoggingFlue.reject(new Error("the error")).toEither(
loggerService,
);
{
"_tag": "Left",
"left": [Error: the error],
}
FlueBuilder supports try
:
await LoggingFlue.try(() => "ok").execute(loggerService);
"ok"
FlueBuilder supports flatMap
:
logs = [];
await LoggingFlue.flatMap((d) =>
getId.try((id) => {
d.log(String(id));
return id;
}),
).execute({ ...loggerService, ...increasingIdService });
20
logs;
["20"];
We can add more dependencies to it by calling .depends
again:
logs = [];
await LoggingFlue.depends(ClockService)
.try((d) => d.log(d.now().toUTCString()))
.execute({
...loggerService,
...clockService,
});
undefined;
logs;
["Sat, 27 May 2023 05:15:46 GMT"];
Collections
FlueBuilder supports all
:
await LoggingFlue.all([
LoggingFlue.resolve(1),
LoggingFlue.resolve(2),
]).execute(loggerService);
[1, 2];
FlueBuilder supports sequence
:
await LoggingFlue.sequence([
LoggingFlue.resolve(1),
LoggingFlue.resolve(2),
]).execute(loggerService);
[1, 2];
Granular Services
Most of the times you would like to use non granular types, as they provide a better Typescript Developer Experience.
As an example, we can create a ProgramCapacities
Service.
type ProgramCapacities = {
now: () => Date;
log: (m: string) => void;
};
const ProgramCapacities = Service<ProgramCapacities>();
const ProgramFlue = Flue.depends(ProgramCapacities);
type ProgramFlue<A> = BuiltBy<typeof ProgramFlue, A>;
And it is simpler to reason and annotate Flues:
const programGetNow = (): ProgramFlue<Date> =>
ProgramFlue.try((d) => d.now());
const programLog = (m: string): ProgramFlue<void> =>
ProgramFlue.try((d) => d.log(m));
programGetNow().try((date, deps) => {
programLog(date.toUTCString()).execute(deps);
});
Granular Types Caveats
We must annotate a supertype of two Flues on branches:
Flue.resolve(1).flatMap(
// Typescript cannot infer the common type
(
it,
): Flue<ClockService & IncreasingIdService, string> => {
if (it > 10) {
return Flue.depends(ClockService).try((d) =>
String(d.now()),
);
} else {
return Flue.depends(IncreasingIdService).try((d) =>
String(d.getId()),
);
}
},
);
In such situations, it is better to extract and annotate that function to improve readability
const handleNumber = (
it: number,
): Flue<ClockService & IncreasingIdService, string> => {
if (it > 10) {
return Flue.depends(ClockService).try((d) =>
String(d.now()),
);
} else {
return Flue.depends(IncreasingIdService).try((d) =>
String(d.getId()),
);
}
};
Flue.resolve(1).flatMap(handleNumber);
Annotations, Inference & Variance
Annotations, Inference & Widening
Code written in Flue will have the types inferred, but we can also annotate its types.
It is safe to do annotate functions without calling .depends
, we are not required to call .depends
to make a Service available. In the end the .depends
call only affects Typescript types.
const loggedFlue01: Flue<LoggerService, void> = Flue.try(
(d) => d.log("Hello"),
);
await loggedFlue01.execute(loggerService);
undefined;
To make it easier to write types, we can use the BuiltBy helper
import { BuiltBy } from "flue-ts";
const LoggedFlue = Flue.depends(LoggerService);
type LoggedFlue<A> = BuiltBy<typeof LoggedFlue, A>;
const loggedFlue02: LoggedFlue<void> = Flue.try((d) =>
d.log("Hello"),
);
await loggedFlue02.execute(loggerService);
undefined;
Notice that annotations are not enough for all situations, and sometimes we must provide the annotation and the .depends
call:
const loggedFlue03: LoggedFlue<string> = Flue
// comment out next line and Typescript fails to check
.depends(LoggerService)
.try((d) => d.log("Hello"))
.try(() => "ok");
logs = [];
await loggedFlue03.execute(loggerService);
"ok"
logs;
["Hello"];
Satisfies
In most situations we want to annotate the return type but let typescript infer the dependencies. We can do so using satisfies:
const loggedFlue04 = Flue.depends(LoggerService).try((d) =>
d.log("Hello"),
) satisfies Flue<any, void>;
logs = [];
await loggedFlue04.execute(loggerService);
undefined;
logs;
["Hello"];
Widening
All Flue combinators widen the return types. If you combine two Flues, the resulting Flue will require all dependencies.
If we remove the return annotation Typescript infers the same
const logId = (id: number): Flue<LoggerService, void> =>
Flue.depends(LoggerService).try((d) => d.log(String(id)));
const getAndLogId = (): Flue<
LoggerService & IncreasingIdService,
void
> => getId.flatMap(logId);
Typescript makes sure we provide all dependencies at execution time.
await getAndLogId().execute({
...increasingIdService,
...loggerService,
});
undefined;
Variance
Flues are covariant to the return type, and contravariant to the dependency types:
As an example, we create a Remote Procedure Call framework. We provide it Flues by calling registerRemoteProcedure
.
We can define a function that accepts Flue that returns { jsonBody: string }
:
const registerRemoteProcedure = (
_procedure: Flue<
LoggerService & ClockService,
{
jsonBody: string;
}
>,
): void => {
// ...
};
We can call registerRemoteProcedure
with a Flue that returns a subtype of string:
const f1 = Flue.resolve({ jsonBody: "ok", b: "ok" });
registerRemoteProcedure(f1);
We can call registerRemoteProcedure
with a Flue that reads a supertype (subset) of the dependencies:
const f2 = Flue.depends(LoggerService).resolve({
jsonBody: "ok",
});
registerRemoteProcedure(f2);
A Flue that uses an extra service (subtype of dependency) is a type error:
const f3 = Flue.depends(LoggerService)
.depends(IncreasingIdService)
.resolve({ a: "not ok" });
//@ts-expect-error
registerRemoteProcedure(f3);
A flue that returns the wrong type (supertype of return) is a type error:
const f4 = Flue.resolve({ b: "not ok" });
//@ts-expect-error
registerRemoteProcedure(f4);
A Flue that reads a subtype of the dependency is a type error:
type Status = "ok" | "bad";
const consumeStringDep = (
it: Flue<
{
a: string;
},
string
>,
) => {
it.execute({ a: "not status" });
};
const fl2: Flue<
{
a: Status;
},
string
> = Flue.try((d) => d.a);
const fl3: Flue<
{
a: string;
},
string
> = Flue.resolve("");
//@ts-expect-error
consumeStringDep(fl2);
consumeStringDep(fl3);