Algebraic effects, in TypeScript.
Handle side effects in a unified way, with type-safety and elegance.
Programming heavily relies on side effects. Imagine a program without I/O capabilities (even basic console access) — it would be pretty useless.
There are many kinds of side effects: I/O operations, error handling, dependency injection, asynchronous operations, logging, and so on.
However, some effects don’t fit well with TypeScript’s type system — for example, error handling, where try-catch blocks only capture unknown types. Other effects, such as asynchronous operations, come with inherent challenges like asynchronous contagion.
That’s where tinyeffect comes in.
tinyeffect provides a unified way to handle all these side effects in a type-safe manner. It’s a tiny yet powerful library with its core logic implemented in only around 400 lines of code. The idea is inspired by the effect system from the Koka language. It uses algebraic effects to model side effects, which are then handled by effect handlers.
Don’t worry if you are not familiar with these concepts. tinyeffect is designed to be simple and easy to use. You can start using it right away without knowing the underlying theory. Simply start with the Usage section to see it in action.
npm install tinyeffect
Consider a simple example. Imagine a function that handles POST /api/users requests in a backend application. The function needs to:
This example demonstrates three types of side effects: dependency injection (retrieving the current user), error handling (checking permissions), and asynchronous operations (database operations). Here’s how these side effects can be handled using tinyeffect and TypeScript:
import { dependency, effect, effected, error } from "tinyeffect";
type User = { id: number; name: string; role: "admin" | "user" };
const println = effect("println")<unknown[], void>;
const executeSQL = effect("executeSQL")<[sql: string, ...params: unknown[]], any>;
const askCurrentUser = dependency("currentUser")<User | null>;
const authenticationError = error("authentication");
const unauthorizedError = error("unauthorized");
const requiresAdmin = () => effected(function* () {
const currentUser = yield* askCurrentUser();
if (!currentUser) return yield* authenticationError();
if (currentUser.role !== "admin")
return yield* unauthorizedError(`User "${currentUser.name}" is not an admin`);
});
const createUser = (user: Omit<User, "id">) => effected(function* () {
yield* requiresAdmin();
const id = yield* executeSQL("INSERT INTO users (name) VALUES (?)", user.name);
const savedUser: User = { id, ...user };
yield* println("User created:", savedUser);
return savedUser;
});
The code above defines five effects: println, executeSQL, currentUser, authentication, and unauthorized. Effects can be defined using effect, with dependency and error as wrappers for specific purposes.
You can define effected programs using the effected function together with a generator function. Inside the generator, simply write the program as if it were a normal synchronous one — just add some yield* where you perform effects or other effected programs.
Hovering over the requiresAdmin and createUser functions in your editor reveals their type signatures:
const requiresAdmin: () => Effected<
| Unresumable<Effect<"error:authentication", [message?: string], never>>
| Effect<"dependency:currentUser", [], User | null>
| Unresumable<Effect<"error:unauthorized", [message?: string], never>>,
undefined
>;
const createUser: (
user: Omit<User, "id">,
) => Effected<
| Unresumable<Effect<"error:authentication", [message?: string], never>>
| Effect<"dependency:currentUser", [], User | null>
| Unresumable<Effect<"error:unauthorized", [message?: string], never>>
| Effect<"executeSQL", [sql: string, ...params: unknown[]], any>
| Effect<"println", unknown[], void>,
User
>;
The inferred type signature shows which effects the program can perform and its return value. We’ll dive into the Effect type in detail later, but for now, let’s explore handling these effects:
const alice: Omit<User, "id"> = { name: "Alice", role: "user" };
const handled = createUser(alice).handle("executeSQL", ({ resume }, sql, ...params) => {
console.log(`Executing SQL: ${sql}`);
console.log("Parameters:", params);
resume(42);
});
We can invoke .handle() on an effected program to handle its effects. The first argument is the effect name, and the second is a handler function. The handler receives an object with two functions: resume and terminate, plus any parameters passed to the effect. You can use resume to continue the program with a value or terminate to halt and return a value immediately as the program’s result.
Since createUser is a function that returns an effected program, we first need to invoke createUser with alice to obtain this program, which then allows us to call .handle() on it to handle its effects, such as executeSQL. Note that while alice is passed to createUser, the program itself still won’t execute at this point. Only after handling all effects will we use .runSync() or .runAsync() to actually execute it, which will be covered later.
Hovering over handled in your editor reveals its type signature:
const handled: Effected<
| Unresumable<Effect<"error:authentication", [message?: string], never>>
| Effect<"dependency:currentUser", [], User | null>
| Unresumable<Effect<"error:unauthorized", [message?: string], never>>
| Effect<"println", unknown[], void>,
User
>;
Let’s handle the rest of the effects.
const handled = createUser(alice)
.handle("executeSQL", ({ resume }, sql, ...params) => {
console.log(`Executing SQL: ${sql}`);
console.log("Parameters:", params);
resume(42);
})
.handle("println", ({ resume }, ...args) => {
console.log(...args);
resume();
})
.handle("dependency:currentUser", ({ resume }) => {
resume({ id: 1, name: "Charlie", role: "admin" });
})
.handle<"error:authentication", void>("error:authentication", ({ terminate }) => {
console.error("Authentication error");
terminate();
})
.handle<"error:unauthorized", void>("error:unauthorized", ({ terminate }) => {
console.error("Unauthorized error");
terminate();
});
For error effects, we specify the return type (void) with a type argument for .handle() since terminate can end the program with any value, and TypeScript can’t infer this type automatically.
After handling all effects, the handled variable’s type signature becomes:
const handled: Effected<never, void | User>;
We get never as the effect list because all effects have been handled. The return type becomes void | User because the program may terminate with void (due to terminate in error handlers) or return a User value.
Let’s run the program. Since all operations are synchronous in this example, we can use .runSync():
handled.runSync();
// Executing SQL: INSERT INTO users (name) VALUES (?)
// Parameters: [ 'Alice' ]
// User created: { id: 42, name: 'Alice', role: 'user' }
What happens if we don’t handle all the effects? Let’s remove some handlers, e.g., the println and currentUser handlers. Now TypeScript will give you an error:
handled.runSync();
// ~~~~~~~
// This expression is not callable.
// Type 'UnhandledEffect<Effect<"dependency:currentUser", [], User | null> | Effect<"println", unknown[], void>>' has no call signatures.
If you ignore this compile-time error, you’ll still encounter a runtime error:
handled.runSync();
// UnhandledEffectError: Unhandled effect: dependency:currentUser()
// at runSync (...)
[!TIP]
You can access the unhandled effect by using the
.effectproperty of the error object:```typescript import { UnhandledEffectError } from "tinyeffect";
try { handled.runSync(); } catch (e) { if (e instanceof UnhandledEffectError) console.error(
Unhandled effect: ${e.effect.name}); } ```
tinyeffect provides concise variants of .handle() to streamline effect handling. These include .resume() and .terminate(), which use the handler’s return value to resume or terminate the program, respectively. Special effects, like errors (names prefixed with "error:") and dependencies (names prefixed with "dependency:"), use .catch(), .provide(), and .provideBy() for specialized handling.
Let’s see how we can rewrite the previous example using these variants:
const handled = createUser(alice)
.resume("executeSQL", (sql, ...params) => {
console.log(`Executing SQL: ${sql}`);
console.log("Parameters:", params);
return 42;
})
.resume("println", console.log)
.provide("currentUser", { id: 1, name: "Charlie", role: "admin" })
.catch("authentication", () => console.error("Authentication error"))
.catch("unauthorized", () => console.error("Unauthorized error"));
What about asynchronous operations? Typically, operations like executeSQL are asynchronous, as they involve I/O and wait for a result. In tinyeffect, synchronous and asynchronous operations are not distinguished, so you can simply call resume or terminate inside an asynchronous callback. Here’s how to handle an asynchronous operation:
const handled = createUser(alice)
.handle("executeSQL", ({ resume }, sql, ...params) => {
console.log(`Executing SQL: ${sql}`);
console.log("Parameters:", params);
// Simulate async operation
setTimeout(() => {
console.log(`SQL executed`);
resume(42);
}, 100);
})
.resume("println", console.log)
.provide("currentUser", { id: 1, name: "Charlie", role: "admin" })
.catch("authentication", () => console.error("Authentication error"))
.catch("unauthorized", () => console.error("Unauthorized error"));
You can then run the program using .runAsync():
await handled.runAsync();
// Executing SQL: INSERT INTO users (name) VALUES (?)
// Parameters: [ 'Alice' ]
// SQL executed
// User created: { id: 42, name: 'Alice', role: 'user' }
If you run an effected program with asynchronous operations using .runSync(), the program will throw an error:
handled.runSync();
// Error: Cannot run an asynchronous effected program with `runSync`, use `runAsync` instead
// at runSync (...)
Sadly, synchronous and asynchronous effected programs can’t be distinguished at compile-time, so running an asynchronous program with .runSync() won’t raise a compile-time error but may fail at runtime. In most cases, though, this shouldn’t be an issue: if you’re building an application with tinyeffect, you’re likely invoking .runSync() or .runAsync() only at the entry point, so there should be only a few places where you need to be careful about this.
[!TIP]
You can use
.runSyncUnsafe()or.runAsyncUnsafe()to run the program without handling all effects. This is useful for testing environments, situations where unhandled effects are not a concern, or cases where you’re certain all effects are handled correctly but TypeScript hasn’t inferred the types as expected.
tinyeffect integrates seamlessly with common APIs that use async/await syntax. For example, say you’re using an API like db.user.create(user: User): Promise<number> to create a user in the database. You might want to write code like this:
// prettier-ignore
const createUser = (user: Omit<User, "id">) => effected(function* () {
yield* requiresAdmin();
const id = await db.user.create(user);
const savedUser: User = { id, ...user };
yield* println("User created:", savedUser);
return savedUser;
});
Since await cannot be used inside a generator function, you might instead create a special effect (e.g., createUser) and handle it later with db.user.create.then(resume), though this approach can be awkward. To address this, tinyeffect provides effectify, a helper function that transforms a Promise into an effected program, allowing yield* in place of await:
```typescript import { effectify } from "tinyeffect";
// prettier-ignore const createUser = (user: Omit) => effected(function () { yield requiresAdmin(); const id = yie
$ claude mcp add tinyeffect \
-- python -m otcore.mcp_server <graph>