Ship

Old Docs · Services And Effects
On this page · 19 sections

Services And Effects

Unified Model

One abstraction for runtime context: an effect — named operations interpreted by a handler/provider at a scope. A service is one kind of effect.

KindDeclarationDischarged byResumptionLifecycleExamples
serviceeffect service Database { … }providetail-resumptive (plain call)yes (t/init/drop); may be ambientDatabase, Email, Clock
controleffect control Yield { … }provide (handler)one-shot resume/returnnoYield, Async, Choice
commandeffect command HttpFetch { … }provide (runtime)runtime-interpretedruntime-ownedTEA Command, HttpFetch

provide installs an interpreter and subtracts a label from the effect channel. A function's eff row is the unified effect channel; typed exceptions remain the separate no-resume sibling (exn — 08-errors.md).

Parses today: effect service, let service. effect control / effect command reserved, not implemented.

Local mutation is not an effect. Builders and scoped editors (Vector.edit, Map.edit, Set.edit) that cannot escape do not enter eff. Observable/cross-boundary state uses services/resources — see 01-syntax.md, book/008-Collections.shp.

Permanently rejected: State(...) and friends

Ship will never add a generic mutable-state effect — no State('s), runState, Koka state/ref, or Ref.get/Ref.set over effects. Use mut, builders, scoped editors, services/resources, and later locality/uniqueness. Do not reopen this. See decisions.md.

Inference: Modal Effect Types

Effect channels are part of the typing judgment (Modal Effect Types, OOPSLA 2025), not copied through explicit row variables on every HOF:

  • App code carries no routine effect annotations; eff shows at boundaries.

  • Ambient services are hidden from ordinary signatures.

  • Higher-order combinators stay annotation-free and accept effectful callbacks:

let stamped = Vector.map(users, f = (u) => (u, Clock.now()));
// call performs Clock; `map` needs no effect annotation

Callback Channel Flow In .shc

// Relative Current flow: callback channels join the call expression
let map: pure fn!('a, 'b)(
  items: Vector('a),
  f: fn('a) -> 'b,
) -> Vector('b);

// Latent-only flow: runs later; channels and escaping mode are explicit
let schedule: pure fn!(
  callback: latent (pure fn!() -> Unit) @ global,
) -> Subscription;
Flow.shc spellingMeaning
Ignoredf: ignored fn(...) -> TNo invocation, forwarding, retention, or escape.
Currentf: fn(...) -> TDefault; channels join this call, no escape.
Latentf: latent (pure fn!(...) -> T) @ globalLatent-only; explicit absolute channels, no current invocation.
CurrentAndLatentf: (fn!(...) -> T, eff Logger) @ globalMay invoke now and retain; current calls join channels.

An escaping mode permits a latent destination but does not remove the default current destination. latent explicitly removes it. @ once and @ required refine invocation count independently. Effects are a may-happen summary (Vector.map([], f) still carries f's possible effects). ignored suppresses only callable flow; channels incurred while evaluating the callback argument expression still behave normally.

At every application, the checker joins the callee's intrinsic Call and each relative callback argument into the ambient ChannelMode = { exn: ExceptionRow, eff: EffectRow }. This relation is visible in .shc; it is never recovered by reading a hidden implementation body.

Contextual Code Is Not A Callback

let code = #code(Postgres.query("select 1"));
let rows = #insert(transform(code));

#code records obligations without performing them; #insert rechecks and contributes services/errors to the insertion site. Code may be passed around at comptime; scope prevents escaping required bindings/providers. Erased before runtime IR. First-slice Code does not expose generic channel rows.

Interfaces And Providers

effect service Email {
  default_from: EmailAddress;
  send_reset: pure fn(email: EmailAddress, token: ResetToken) -> Unit,
    exn [ :smtp(SmtpError) ];
}

let service SmtpEmail: Email = {
  type t = {
    client: Smtp.Client;
    from: EmailAddress;
  }

  init = fn() -> t,
    exn [ :secret(...) | :smtp(...) ],
    eff Secrets
  {
    let smtp_url = Secrets.require("SMTP_URL");
    return { client = Smtp.connect(smtp_url); from = ...; };
  };

  drop = fn(self: t) -> Unit {
    Smtp.Client.close(self.client);
  };

  default_from = fn(self: t) -> EmailAddress {
    return self.from;
  };

  send_reset = fn(self: t, email: EmailAddress, token: ResetToken)
    -> Unit,
    exn [ :smtp(SmtpError) ]
  {
    ...
  };
};

Interfaces name constants and operations — not provider state, not product types. Nominal: same shapes ≠ same capability. Builtin Provider(Effect) covers ad hoc / request-scoped providers (tests, CurrentUser, route steps); install with the same provide.

Calls enter eff at the call site — no routine use Email:

Email.send_reset(user.email, token);
// inferred eff [ Postgres | Email | Auth ]  (ambient Clock hidden)

Discharge: provide

provide {
  Config   = EnvConfig;
  Postgres = MainPostgres;
  Email    = SmtpEmail;
} {
  Server.run(routes);
}

The RHS may be a named let service provider or an expression returning Service.Provider(Service):

provide {
  AppDb = Sqlite.file(AppDb, ".state/app.sqlite");
} {
  run_app();
}

Provider expressions are evaluated once for the scope, then their checked Provider recipe follows the same init/drop lifecycle as a named provider. The RHS is checked against the left-hand service before that service is removed from the body's effect row. Control and command bindings use their corresponding handler/runtime kinds; one block may mix kinds.

Binding Order (locked)

  • Services: unordered set; topo-sorted from init … eff …; cycles rejected; drop reverse topo. Textual position irrelevant.

  • Control: ordered nesting stack (top = outer). Observable for dispatch and return-clause transforms.

  • Commands: like services (usually once at root).

  • Mixed block → nest of single-effect installs; result type = outermost control return (identity for services/commands).

Lifecycle And Failure

Init once per scope (uninterruptible); init failure drops already-started providers and skips the rest. drop runs however the scope ends, outside its own binding, and a failing drop joins the scope's flat Cause. drop = fn(self: t, exit: ScopeExit('error)) sees how the body ended (Resource scopes); drop(self) ignores it.

Test Provider Factories

suite "forgot password" {
  providing Clock = Test.Service.per_test((_test: Test.Case) =>
    module: Service.Provider(Clock) #{
      let service Provider: Clock = FixedClock("2026-05-27T00:00:00Z");
    }
  );
  providing Email = Test.Service.per_test((_test: Test.Case) =>
    module: Service.Provider(Email) #{
      let service Provider: Email = RecordingEmail;
    }
  );

  test "sends reset email" {
    send_reset(...);
    expect RecordingEmail.sent_count() == 1;
  }
}

Test.Service.per_test and per_suite are ordinary standard-library functions, not service-specific compiler rules. Each takes a pure factory over an ordinary Test.Case / Test.Suite value and returns a named or anonymous provider module. The factory constructs the provider recipe; its init acquires the resource and outcome-aware drop releases it. per_test invokes the factory for every selected attempt; per_suite invokes it once per selected suite instance. Source order remains provider dependency order and teardown reverses it.

Runtime provide { ... } { ... } remains available for narrow overrides inside one test body. Test-level providing is declarative so ship test can discover, filter, preflight, and schedule service lifecycles before execution.

Multiple Instances

  • Multiplicity is nominal or keyed — never positional/mask.

  • Ambiguity is a compile error, not innermost-wins.

StageWhenShape
0Different authorityDistinct nominal effects (PrimaryDb vs ReplicaDb)
1Same interface, known familyKeyed labels: Database(:primary), Cache(User)
2Unbounded runtime familiesFirst-class scoped handles — deferred (../questions/042-first-class-effect-instances.md)

One in scope → bare call. Two+ → disambiguate. Nested same key → intentional override. Duplicate key in one block → error.

Transactions

Transactions owns the design. Read/write split uses Stage-0 distinct effects.

Ambient Vs Explicit

Ambient (always available, hidden from ordinary eff display, overrideable): Clock, Console, Random, Logger, Config, Runtime.

Security splits stay explicit: CryptoRandom, AuditLog, Secrets, Process/Shell vs ambient peers.

Explicit (appear in eff): Postgres, FileSystem, HttpClient, Email, Queue, ObjectStorage, DurableStorage, PaymentGateway, Secrets, CryptoRandom, Process, Shell, Browser, Clipboard, ActorSystem, …

Rule: outside process, credentials, irreversible, or deployment-varying → explicit. Target-specific (Browser) → diagnostics on other backends, not polyfills. Native servers install typed FileSystem, Secrets, SQLite, and HTTP providers; app code gets no ambient host module access. HttpClient uses portable Http.Request/Response — 18-http.md. Entry points still provide the module-qualified interpreter (FileSystem.LocalFileSystem, HttpClient.NativeHttpClient); the target does not make those services ambient.

Colorless I/O

Service operations return values. They do not return Async.Task, and they do not use Result-shaped Outcome wrappers. A provider may block in native code or suspend in a Browser host adapter without changing the source signature or adding Async to eff.

effect service FileSystem {
  read_string: pure fn(root: String, path: String) -> String, exn [ :fs(FileSystem.Error) ];
}

let load = fn(root: String, path: String) -> String, eff FileSystem {
  return FileSystem.read_string(root, path);
}

provide { FileSystem = FileSystem.LocalFileSystem; } {
  let text = load(".", "README.md");
}

Do not split FileSystem.Sync / FileSystem.Async, stamp async fn on I/O, or leak host promises into .shc return types. Host yielding is a backend / provider fact — 167-js-async-provider-abi.md.

Source eff Vs Host Operational Effects

Source eff tracks provide-discharged requirements only. pure fn means eff = [] — not "no host I/O" and not referential transparency.

Free #builtin host APIs that are not service operations (Password hashing, Terminal secret reads, dialect Connection connect/query/close) seal the effect axis with pure and keep operational effectfulness on the provider:

  • #builtin comptime = Effectful blocks pure-comptime evaluation

  • Future backend claim descriptors own allocation / blocking / re-entry (provider semantic ABI)

    Do not invent effect service Password / Terminal, or stamp free Connection APIs with dialect eff Postgres / eff Sqlite, just to satisfy .shc sealing. Dialect services own provider execute only; Connection handles carry authority as values (database).

    Contrast settled service-backed hosts:

effect service Console { ... }   // ambient; overrideable
let print: fn!(...) -> Unit, eff Console = #builtin(...);

effect service Postgres {
  execute: pure fn(...) -> Vector('row), exn [ :db(String) ];
}

// Free Connection primitives — empty source eff; host ops via provider facts
let connect: pure fn(url: String) -> Connection, exn [ :db(String) ] = #builtin(
  id = "postgres_connect",
  runtime = %{ Native },
  comptime = Effectful,
);
let hash: pure fn(password: String) -> String, exn [ :password(String) ] = #builtin(
  id = "password_hash",
  runtime = %{ Native },
  comptime = Effectful,
);

Declare a new effect service only when provide / override is a real requirement (tests, sandbox, alternate backends). Fold into Crypto / Process only after those services exist with a provider story — not as syntax labels.

Implementation Model

Type-system feature; default impl = static hidden capability arguments (not broad runtime lookup). Service ops → plain calls. Control ops → one-shot delimited continuations (OxCaml-effects model — research/questions 122–128).

Frontend Program Channels

Browser TEA/Elm programs use compiler-created command / outcome open-row aliases — finite structural data the runtime or parent interprets:

type command = [ | :fetch_user(UserId) ]
type outcome = [ | :saved(User) ]

let update = (model, message) => match message {
  | :clicked_refresh -> {
    command(:fetch_user(model.user_id));
    model
  }
  | :saved_from_server(user) -> {
    outcome(:saved(user));
    { model with user = Some(user) }
  }
};

update stays model-only at source. Tests assert on reified commands and override interpretation with provide fresh — 14-browser-programs.md.

Open Follow-Ups

  • Inline handler { … } / command runtime surface

  • Transaction surface details (read-only, explicit :tx, retry placement)

  • Scoped runtime context — ../questions/041-scoped-runtime-context.md

  • Cleanup with suspended continuations — question 126

  • Context polymorphism for Code channel rows

08-errors.md, 02-type-inference.md, book/003-Functions.shp, book/005-Databases/000-init.shp, 14-resource-cleanup.md, 01-syntax.md, ../research/oxcaml-effects.md.