0.0.10 • Published 7 months ago

flue-ts v0.0.10

Weekly downloads
-
License
ISC
Repository
-
Last release
7 months ago

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);
0.0.10

7 months ago

0.0.9

8 months ago

0.0.8

8 months ago

0.0.7

9 months ago

0.0.6

10 months ago

0.0.5

10 months ago

0.0.4

10 months ago

0.0.3

12 months ago

0.0.2

12 months ago

0.0.1

12 months ago