effx
Getting started

Getting started

Walk the examples/users slice end to end, from declarations to check, build, inspect, graph, and a running server, client and CLI.

This page follows the examples/users slice. Every code block below is included from the real file, so it cannot drift from the repository.

effx is not published. @effx/cli and @effx/runtime are workspace packages at version 0.1.0 (packages/cli/package.json) and no npm install flow exists yet. Every command here runs from a checkout of the monorepo. STATE.md records that mono-web consumes a locally packed, vendored distribution. The root script bun run pack builds the local tarballs (package.json, scripts/pack.ts).

Two ways to call the CLI today

WhereCommandNotes
Monorepo rootbun run effx <command> --project examples/users/tsconfig.jsonRoot script effx is bun scripts/effx.ts. --project is resolved against the working directory.
Example packagebun run --cwd examples/users effx:buildThe script effx:build in examples/users/package.json is bun ../../scripts/effx.ts build. --project defaults to tsconfig.json in that directory.
Published packagenot availableNothing is published. Do not rely on a bunx effx or npm install flow.

Install the workspace dependencies first with bun install at the repository root. AGENTS.md notes that the Oxlint plugin dependency is a local path pin to a sibling checkout.

What you build

examples/users/src/
  schemas.ts        Effect Schemas: ids, inputs, views, tagged errors
  user.ts           @PersistentModel User with Public and Self views
  services.ts       Users service + in-memory layer
  capabilities.ts   Read, ChangeEmail capabilities
  operations.ts     the two annotated operations
        │ effx build
        ▼
examples/users/.effx/
  ir.json   manifest.json   generated/{http,rpc,cli,client}.ts

Source: docs/specs/0003-cli-and-vertical-slice.md "Journey" and "examples/users".

1. Declare

Schemas are plain Effect Schema values. The compiler keeps them as references and never serializes them (Schema refs).

examples/users/src/schemas.ts
import { Schema } from "effect";

export const UserId = Schema.String.pipe(Schema.brand("UserId"));

export const Email = Schema.NonEmptyString;

export const GetUserInput = Schema.Struct({ id: UserId });

export const ChangeEmailInput = Schema.Struct({ id: UserId, email: Email });

export const UserPublic = Schema.Struct({ id: UserId, displayName: Schema.String });

export const UserSelf = Schema.Struct({ id: UserId, email: Email, displayName: Schema.String });

export class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
  id: UserId,
}) {}

export class EmailTaken extends Schema.TaggedError<EmailTaken>()("EmailTaken", {
  email: Email,
}) {}

The model ties a table and two views to the User class.

examples/users/src/user.ts
import { PersistentModel } from "@effx/runtime";
import { Schema } from "effect";
import { Email, UserId, UserPublic, UserSelf } from "./schemas.ts";

@PersistentModel({
  table: "users",
  views: { Public: UserPublic, Self: UserSelf },
  focus: { email: ["email"] },
})
export class User extends Schema.Class<User>("User")({
  id: UserId,
  email: Email,
  displayName: Schema.String,
}) {
  static readonly Public = UserPublic;
  static readonly Self = UserSelf;
}

The service is an ordinary Context.Service. The layer is an in-memory store, not durable across restarts.

examples/users/src/services.ts
import { Context, Effect, HashMap, Layer, Option, Ref, Result } from "effect";
import { Email, EmailTaken, UserId, UserNotFound } from "./schemas.ts";
import { User } from "./user.ts";

/** An in-process example store. Successful writes are visible to this layer's readers, but are not durable across restarts. */
export class Users extends Context.Service<
  Users,
  {
    readonly find: (id: typeof UserId.Type) => Effect.Effect<User, UserNotFound>;
    readonly setEmail: (
      id: typeof UserId.Type,
      email: typeof Email.Type,
    ) => Effect.Effect<User, UserNotFound | EmailTaken>;
  }
>()("@effx-examples/users/src/services/Users") {
  static readonly layer = Layer.effect(
    Users,
    Effect.gen(function* () {
      const alice = new User({
        id: UserId.make("1"),
        email: "alice@example.com",
        displayName: "Alice",
      });

      const bob = new User({ id: UserId.make("2"), email: "bob@example.com", displayName: "Bob" });

      const users = yield* Ref.make<HashMap.HashMap<typeof UserId.Type, User>>(
        HashMap.make([alice.id, alice], [bob.id, bob]),
      );

      const find = Effect.fn("Users.find")(function* (id: typeof UserId.Type) {
        const user = HashMap.get(yield* Ref.get(users), id);

        if (Option.isNone(user)) return yield* new UserNotFound({ id });

        return user.value;
      });

      const setEmail = Effect.fn("Users.setEmail")(function* (
        id: typeof UserId.Type,
        email: typeof Email.Type,
      ) {
        const result = yield* Ref.modify(
          users,
          (
            current,
          ): readonly [
            Result.Result<User, UserNotFound | EmailTaken>,
            HashMap.HashMap<typeof UserId.Type, User>,
          ] => {
            const found = HashMap.get(current, id);

            if (Option.isNone(found)) {
              return [Result.fail(new UserNotFound({ id })), current];
            }

            for (const [otherId, other] of current) {
              if (otherId !== id && other.email === email) {
                return [Result.fail(new EmailTaken({ email })), current];
              }
            }

            const updated = new User({
              id: found.value.id,
              email,
              displayName: found.value.displayName,
            });

            return [Result.succeed(updated), HashMap.set(current, id, updated)];
          },
        );

        return yield* Effect.fromResult(result);
      });

      return Users.of({ find, setEmail });
    }),
  );
}

Capabilities name what an operation is allowed to do, against a model and an optional focus path.

examples/users/src/capabilities.ts
import { Capability, Focus } from "@effx/runtime";
import { User } from "./user.ts";

export const Read = Capability.make("User.Read", { resource: User });

export const ChangeEmail = Capability.make("User.ChangeEmail", {
  resource: User,
  focus: Focus.key(User, "email"),
});

The operations carry the annotations. Each decorator contributes to the IR. None of them changes how the method runs (The invariant).

examples/users/src/operations.ts
import { Authorize, Cli, Command, Errors, Http, Query, Requirements, Rpc } from "@effx/runtime";
import { Effect } from "effect";
import { ChangeEmail, Read } from "./capabilities.ts";
import { ChangeEmailInput, EmailTaken, GetUserInput, UserNotFound } from "./schemas.ts";
import { Users } from "./services.ts";
import { User } from "./user.ts";

export class UserOperations {
  @Query({ name: "User.Get", input: GetUserInput, success: User.Public })
  @Http.Get("/users/:id")
  @Rpc("User.Get")
  @Cli("users get")
  @Authorize(Read)
  static get(input: typeof GetUserInput.Type) {
    return Effect.gen(function* () {
      const users = yield* Users;
      const user = yield* users.find(input.id);

      return { id: user.id, displayName: user.displayName };
    });
  }

  @Command({ name: "User.ChangeEmail", input: ChangeEmailInput, success: User.Self })
  @Http.Patch("/users/:id/email")
  @Rpc("User.ChangeEmail")
  @Cli("users change-email")
  @Authorize(ChangeEmail)
  @Errors(UserNotFound, EmailTaken)
  @Requirements(Users)
  static changeEmail(input: typeof ChangeEmailInput.Type) {
    return Effect.gen(function* () {
      const users = yield* Users;

      return yield* users.setEmail(input.id, input.email);
    });
  }
}
AnnotationWhat it contributes
@Query, @CommandAn Operation node with input and success schema references
@Http.Get, @Http.PatchAn Exposure node with an ExposedAs edge
@Rpc, @CliThe same, for RPC and CLI exposure
@AuthorizeAn AuthorizedBy edge to a capability
@Errors, @RequirementsDeclared error and requirement sets, checked against what the handler infers

Source: docs/specs/0001-compiler-kernel.md annotation argument shapes. The authoring surface is documented under /docs/authoring/http.

2. Check

check runs collect, interpret and analyze. It prints diagnostics and writes nothing.

bun run effx check --project examples/users/tsconfig.json

The output ends with a summary line, N error(s), M warning(s), K info. The exit code is 1 if any diagnostic has severity error, otherwise 0. See effx check.

3. Build

bun run --cwd examples/users effx:build

build runs check, then writes .effx/ir.json, .effx/manifest.json and the generated files. STATE.md records this build as zero errors, four generated files, and TypeScript-version skew reported as EFFX0001 (later entries add two unprotected-User EFFX2504 warnings). See effx build.

.effx/ is gitignored. The example tsconfig.json includes .effx/generated/**/*.ts, so run the build before typechecking or running the example (spec 0003).

4. Inspect

bun run effx inspect User.ChangeEmail --project examples/users/tsconfig.json

inspect prints one operation's contract and exposures. The format is defined in spec 0003. This block is the spec's format example, not captured from a run for this page.

Operation User.ChangeEmail

Kind
  Command

Contract
  ChangeEmailInput → User.Self

Errors
  EmailTaken
  UserNotFound

Requirements
  Users

Authority
  User.ChangeEmail
  resource: User
  focus: User.email

Exposed
  PATCH /users/:id/email
  RPC User.ChangeEmail
  CLI users change-email

See effx inspect.

5. Graph

bun run effx graph User.Get --project examples/users/tsconfig.json

graph prints a Mermaid flowchart LR of the whole IR, or of one node plus everything reachable from it. See effx graph.

6. Run it

The generated http.ts exports AppRoutes. server.ts re-exports it and server-main.ts is the composition root. You provide the layers. effx adds no runtime of its own.

examples/users/src/server-main.ts
import { BunHttpServer, BunRuntime } from "@effect/platform-bun";
import { Config, Effect, Layer } from "effect";
import { AppRoutes } from "./server.ts";
import { Users } from "./services.ts";

const Server = Layer.unwrap(
  Effect.map(Config.Number("PORT").pipe(Config.withDefault(3000)), (port) =>
    AppRoutes.pipe(Layer.provide(Users.layer), Layer.provide(BunHttpServer.layer({ port }))),
  ),
);

BunRuntime.runMain(Layer.launch(Server));

The examples/users/package.json scripts:

ScriptCommand it runsNeeds
effx:buildbun ../../scripts/effx.ts buildnothing
servebun src/server-main.tsa prior build. Reads PORT (default 3000).
clientbun src/client-main.tsa prior build and a running server. Reads EFFX_URL (default http://localhost:3000).
clibun src/cli-main.tsa prior build

Run each with bun run --cwd examples/users <script>. STATE.md records these runs from the CLI/users evidence:

PORT=31063 bun run --cwd examples/users serve
EFFX_URL=http://127.0.0.1:31063 bun run --cwd examples/users client
bun run --cwd examples/users cli users get --input '{"id":"1"}'
bun run --cwd examples/users cli users change-email --input '{"id":"1","email":"alice+cli@example.com"}'

The recorded results: GET /users/1 and PATCH /users/1/email returned HTTP 200, the client logged the public and self views, and each generated CLI command printed one JSON success line.

Next

On this page