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.
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 propagatesIf 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.
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.
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)),
});At the boundary of your program, where you need a concrete value, you have two options.
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!",
})
})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 errOne-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)); // 84List.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): returnsFect.err(...)instead ofundefined.findMapOrErr(fn, errorOrThunk): same forfindMap.
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);| 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.
MIT