MCPcopy Index your code
hub / github.com/Snowflyt/tinyeffect

github.com/Snowflyt/tinyeffect @v0.3.4

Chat with this repo
repository ↗ · DeepWiki ↗ · release v0.3.4 ↗ · + Follow
275 symbols 1,048 edges 19 files 13 documented · 5%
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

tinyeffect

Algebraic effects, in TypeScript.

Handle side effects in a unified way, with type-safety and elegance.

npm version minzipped size test status coverage status MIT license

screenshot

About

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.

Installation

npm install tinyeffect

Usage

Consider a simple example. Imagine a function that handles POST /api/users requests in a backend application. The function needs to:

  • Retrieve the current logged-in user from the context.
  • Check if the user has permission to create a new user (in this case, only admin users can create new users). If not, an error is thrown.
  • If the user has the necessary permission, create a new user in the database.

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 .effect property 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

Extension points exported contracts — how you extend this code

User (Interface)
* Repositories *
test/backend-app.spec.ts
EffectedDraft (Interface)
(no doc) [1 implementers]
src/effected.ts
StringifySerializerRegistry (Interface)
(no doc)
test/typroof-env.d.ts
Settings (Interface)
(no doc)
test/README.example.spec.ts
Settings (Interface)
(no doc)
test/README.example.proof.ts
Worker (Interface)
(no doc)
test/test-env.d.ts
UnhandledEffect (Interface)
(no doc)
src/types.d.ts
EffectSerializer (Interface)
(no doc)
test/typroof-env.d.ts

Core symbols most depended-on inside this repo

effected
called by 218
src/effected.ts
effect
called by 173
src/effected.ts
resume
called by 165
src/effected.ts
of
called by 112
src/effected.ts
resume
called by 68
src/effected.ts
handle
called by 54
src/effected.ts
andThen
called by 54
src/effected.ts
error
called by 40
src/effected.ts

Shape

Function 200
Method 45
Class 16
Interface 14

Languages

TypeScript100%

Modules by API surface

src/effected.ts72 symbols
test/README.example.proof.ts55 symbols
test/README.example.spec.ts53 symbols
test/koka-samples.spec.ts30 symbols
test/effected.spec.ts24 symbols
test/backend-app.spec.ts9 symbols
bench/fib.bench.ts9 symbols
test/test-env.d.ts4 symbols
scripts/generate-pipe-overloads.ts4 symbols
test/typroof-env.d.ts3 symbols
test/effected.proof.ts3 symbols
src/types.js3 symbols

For agents

$ claude mcp add tinyeffect \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact