Skip to content

goodpuppies/fect

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

34 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fect

Simple TypeScript library for effects and pattern matching.

Fect reduces function coloring, the hard boundary between async/sync, effectful/pure, fallible/infallible code, without a DSL, generators, or pipe chains. Wrap your functions with Fect.fn, compose them normally, and unwrap at the boundary.

import { Fect } from "@goodpuppies/fect";

interface User {
  login: string;
  name?: string;
}

const fetchUser = Fect.fn((name: string): Promise<User> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        login: name,
        name: name.toUpperCase(),
      });
    }, 1);
  });
});

const getDisplayName = Fect.fn((user: User) => user.name ?? user.login);

// getDisplayName expects a User, but gets the async Fect from fetchUser.
// It just works, no await, no unwrap, no flatMap.
const name = getDisplayName(fetchUser("denoland"));

// Unwrap once at the boundary.
console.log(await Fect.try(name));

getDisplayName is a plain sync function. It doesn't know or care that its input is async. Fect handles that automatically, async propagates through the chain and you discharge it once, when you need the concrete value.

How It Works

Fect.fn wraps a function so it accepts both plain values and Fect-wrapped values. When you pass a wrapped value, the function chains onto it automatically. When you pass a plain value, it behaves like a normal function call.

Fect.fnCatch is the always-carrier variant. It returns a Fect even for plain sync calls and catches sync throws into the err channel.

import { Fect } from "@goodpuppies/fect";

const double = Fect.fn((x: number) => x * 2);
const load = Fect.fn(async () => 5);
const someAsyncFect = load();

double(5); // plain 10, not wrapped
double(Fect.ok(5)); // Fect<10>, stays in Fect-land
double(someAsyncFect); // async Fect<10>, async propagates

If you've used promises, you've already seen the core idea. In JavaScript, Promise.resolve(Promise.resolve(123)) gives you Promise<number>, not Promise<Promise<number>> the runtime flattens nested promises automatically. Fect does the same thing: when you pass a wrapped value to a Fect function, it flattens. You never get Fect<Fect<number>>. The inner value is always unwrapped before your handler runs, whether it's async, an error, or both.

Rust's ? operator is another take on this, it unwraps Result at each call site. But ? is still function coloring: you write it explicitly, and it changes the function's return type. Fect has no operator. You just call functions.

This is similar to Effect's approach but without the generator/DSL layer. You write normal TypeScript functions, wrap them with Fect.fn, and compose with regular function calls. Fect stays out of your way until something is actually effectful.

Errors

Tagged Errors

Declare errors in one line:

import { Fect } from "@goodpuppies/fect";

class NotFound extends Fect.error("NotFound")<{ id: string }>() {}
class Unauthorized extends Fect.error("Unauthorized")() {}

Return errors from Fect.fn handlers with .err():

import { Fect } from "@goodpuppies/fect";

type User = { id: string; name: string };

class NotFound extends Fect.error("NotFound")<{ id: string }>() {}
class Unauthorized extends Fect.error("Unauthorized")() {}

const loadUser = Fect.fn(async (id: string) => {
  const res = await fetch(`/api/users/${id}`);
  if (res.status === 404) return NotFound.err({ id });
  if (res.status === 401) return Unauthorized.err();
  return (await res.json()) as User;
});

Errors short-circuit, downstream steps don't run if an upstream step failed.

Defects

Unhandled throws and rejected promises are caught automatically and tagged as UnknownException or PromiseRejected. You can customize this:

import { Fect } from "@goodpuppies/fect";

class MyError extends Error {}
const handler = (_value: string) => "ok";

Fect.fn(handler, {
  mapThrown: (cause) => new MyError(String(cause)),
  mapRejected: (cause) => new MyError(String(cause)),
});

Unwrapping

At the boundary of your program, where you need a concrete value, you have two options.

Pattern Matching

Fect.match gives you exhaustive, type-safe matching on both Fect carriers and plain values:

import { Fect } from "@goodpuppies/fect";

class NotFound extends Fect.error("NotFound")<{ id: string }>() {}
class Unauthorized extends Fect.error("Unauthorized")() {}

const loadUser = Fect.fn((id: string) => {
  if (id === "missing") return NotFound.err({ id });
  if (id === "blocked") return Unauthorized.err();
  return { id, name: "Ada" };
});

const result = loadUser(Fect.ok("42"));

const message = Fect.match(result).with({
  ok: (user) => `Hello, ${user.name}`,
  err: {
    NotFound: (e) => `No user ${e.id}`,
    Unauthorized: () => "Access denied",
  },
});

Miss an error branch and TypeScript will tell you at compile time.

If you want to handle only some tagged errors and keep the rest infected, use Fect.partial:

import { Fect } from "@goodpuppies/fect";

class InputEmpty extends Fect.error("InputEmpty")() {}
type User = { login: string; repos_url: string };

const fetchUser = Fect.fn((name: string) => {
  if (name.trim().length === 0) return InputEmpty.err();
  return { login: name, repos_url: `/api/repos/${name}` };
});

const fetchRepos = Fect.fn((user: User) => [user.repos_url]);
const user = fetchUser("");

const userWithoutInputError = Fect.partial(user).with({
  err: {
    InputEmpty: () => {
      return { login: "guest", repos_url: "/api/repos/guest" };
    },
  },
});

// `InputEmpty` is removed from the error channel here.
const repos = fetchRepos(userWithoutInputError);

partial also works on plain values. That lets you dig through nested data without handling every failed access immediately, then discharge once at the boundary with a normal match:

import { Fect } from "@goodpuppies/fect";

const readText = Fect.fnCatch(Deno.readTextFileSync);
const parseJson = Fect.fnCatch(JSON.parse);

const portResult = Fect.partial(
  parseJson(
    readText("config.json")
  )
).with({
  ok: (config) =>
    Fect.partial(config).with({
      object: (root) =>
        Fect.partial((root as { server?: unknown }).server).with({
          object: (server) =>
            Fect.partial((server as { port?: unknown }).port).with({
              number: (port) => port,
            }),
        }),
    }),
});

const port = Fect.match(portResult).with({
  ok: (port) => port,
  err: () => 8080,
  unknown: () => 8080,
});

For non-tagged errors, pass a function:

import { Fect } from "@goodpuppies/fect";

const result = Fect.err("boom");

Fect.match(result).with({
  ok: (v) => v,
  err: (e) => `failed: ${e}`,
});

match also works on plain values:

import { Fect } from "@goodpuppies/fect";

type UserType = "admin" | "user";

const login = Fect.fn((userType: UserType) => {
  return Fect.match(userType).with({
    admin: () => "Welcome, Administrator!",
    user: () => "Hello, User!",
  })
})

Try

Fect.try extracts the value directly, throwing on error:

import { Fect } from "@goodpuppies/fect";

const syncResult = Fect.ok({ login: "sync-user" });
const asyncResult = Fect.fn(async () => ({ login: "async-user" }))();

const syncUser = Fect.try(syncResult); // throws if err
const asyncUser = await Fect.try(asyncResult); // rejects if err

RemoteValue

One-shot async rendezvous for values that arrive later, from another actor, a WebSocket, a callback:

import { Fect } from "@goodpuppies/fect";

const rv = Fect.remoteValue<number>({ timeoutMs: 5000 });
const double = Fect.fn((n: number) => n * 2);

// Fill from anywhere:
rv.fill(42);

// Compose like any other value:
const doubled = double(rv);
console.log(await Fect.try(doubled)); // 84

LazyList

List.lazy(...) gives you sequential lazy traversal over arrays and mixed effectful values (plain, Promise, Fect, Fect.lazy).

map accepts normal callbacks and also overloaded Fect.fn callbacks directly:

import { Fect, List } from "@goodpuppies/fect";

class MessageNotFound extends Fect.error("MessageNotFound")<{ id: string }>() {}
class VersionParseFailed
  extends Fect.error("VersionParseFailed")<{ snippet: string }>() {}
class NoVersionForPlatform
  extends Fect.error("NoVersionForPlatform")<{ platform: "iOS" | "macOS" }>() {}

const toMsg = Fect.lazy(async (item: { id: string }) => {
  // ...load message
  return Fect.err(MessageNotFound.of({ id: item.id })); // just an example path
});

const versionStr = Fect.fn((snippet: string) => {
  const v = snippet.match(/(\d+\.[\d.]+)\s*\((\d+)\)/)?.[0];
  if (!v) return Fect.err(VersionParseFailed.of({ snippet }));
  return v;
});

const latestVersion = await List.lazy([{ id: "a" }, { id: "b" }])
  .map(toMsg)
  .recoverTag("MessageNotFound")
  .map((msg) => msg.snippet)
  .map(versionStr) // overloaded Fect.fn callback works directly
  .recoverTag("VersionParseFailed")
  .firstOrErr(NoVersionForPlatform.of({ platform: "iOS" }));

Common helpers:

  • recoverTag("Tag"): recover matching tagged errors by skipping that item.
  • recoverTag("Tag", handler): recover with a replacement value.
  • firstOrErr(errorOrThunk): returns Fect.err(...) instead of undefined.
  • findMapOrErr(fn, errorOrThunk): same for findMap.

Full Example

import { Fect } from "@goodpuppies/fect";

class InputEmpty extends Fect.error("InputEmpty")() {}
class HttpError
  extends Fect.error("HttpError")<{ status: number; where: string }>() {}

const parseInput = Fect.fn((raw: string) => {
  const trimmed = raw.trim();
  if (trimmed.length === 0) return InputEmpty.err();
  return trimmed;
});

const fetchUser = Fect.fn(async (username: string) => {
  const res = await fetch(`https://api.github.com/users/${username}`);
  if (!res.ok) return HttpError.err({ status: res.status, where: "fetchUser" });
  return (await res.json()) as { login: string; repos_url: string };
});

const fetchRepos = Fect.fn(async (user: { repos_url: string }) => {
  const res = await fetch(user.repos_url);
  if (!res.ok) {
    return HttpError.err({ status: res.status, where: "fetchRepos" });
  }
  return (await res.json()) as { name: string; stargazers_count: number }[];
});

const summarize = Fect.fn((
  repos: { name: string; stargazers_count: number }[],
) =>
  repos.sort((a, b) => b.stargazers_count - a.stargazers_count)
    .slice(0, 3)
    .map((r) => `${r.name} (${r.stargazers_count}★)`)
);

// Compose, no intermediate awaits, no unwrapping between steps.
const parsed = parseInput("denoland");
const user = fetchUser(parsed);
const repos = fetchRepos(user);
const result = summarize(repos);

// Unwrap at the boundary with exhaustive matching.
const message = await Fect.match(result).with({
  ok: (top) => `Top repos: ${top.join(", ")}`,
  err: {
    InputEmpty: () => "Error: empty input",
    HttpError: (e) => `Error: HTTP ${e.status} at ${e.where}`,
    PromiseRejected: () => "Error: network failure",
    UnknownException: (e) => `Error: ${e.cause}`,
  },
});

console.log(message);

API

Function Description
Fect.fn(handler, options?) Wrap a function (pure sync stays plain)
Fect.fnCatch(handler, options?) Wrap and always return a carrier
Fect.ok(value) Construct a success carrier
Fect.err(error) Construct an error carrier
Fect.fail(error) Signal an error inside a handler
Fect.error(tag)<Fields>() Declare a tagged error class
Fect.match(input).with({...}) Pattern match on carriers or plain values
Fect.partial(input).with({...}) Match some tagged errors and continue flow
Fect.try(carrier) Extract value or throw
Fect.isOk(carrier) Check if carrier holds a value
Fect.isErr(carrier) Check if carrier holds an error
Fect.isFect(value) Runtime type guard
Fect.remoteValue(options?) Create a one-shot async rendezvous
List.lazy(items) Create a lazy sequential list pipeline
List.recoverTag(items, tag) Skip items that fail with matching tag
List.firstOrErr(items, error) Return typed err when list result is empty
List.findMapOrErr(...) findMap that returns typed err on empty

All functions are also available as named exports (fn, fnCatch, ok, err, match, etc.) if you prefer destructured imports.

License

MIT

About

a pragmatic ts effect library

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors