Skip to content

Process Entrypoints (runMain)

When an Effect is the root of a process — a CLI command, a server, a worker — you want more than a bare runFork. You want failures reported with a non-zero exit code, SIGINT/SIGTERM (Ctrl+C, container stop) to interrupt the program so finalizers run, and the process to exit cleanly once work is done. That is exactly what runMain provides.

runMain is the last line of your main.ts: build the program, provide every layer it needs, then hand the self-contained Effect<A, E, never> to your platform’s runner.

main.ts
import { NodeRuntime } from "@effect/platform-node"
import { Effect, Layer } from "effect"
// A small worker that logs and then spawns a background loop. `forkScoped`
// ties the loop's lifetime to the surrounding scope, so it is interrupted
// automatically on shutdown.
const Worker = Layer.effectDiscard(
Effect.gen(function*() {
yield* Effect.logInfo("Starting worker...")
yield* Effect.forkScoped(
Effect.gen(function*() {
while (true) {
yield* Effect.logInfo("Working...")
yield* Effect.sleep("1 second")
}
})
)
})
)
// `Layer.launch` turns the layer into a long-running Effect (see below).
const program = Layer.launch(Worker)
// Make it the process root. runMain installs SIGINT/SIGTERM handlers and
// interrupts running fibers for graceful shutdown.
NodeRuntime.runMain(program)

Under the hood, runMain is Effect.runFork plus the boundary behaviour every real program needs:

  • Error reporting — an unreported, non-interruption failure cause is logged (via Effect.logError) and the process exits with a non-zero code. Interruptions are not treated as errors.
  • Signal handlingSIGINT and SIGTERM interrupt the main fiber rather than killing the process abruptly, giving scoped finalizers a chance to run.
  • Keep-alive and teardown — the process stays alive while the main fiber is running, then tears down (and sets the exit code) once it completes.

The default exit-code mapping (Runtime.defaultTeardown) is:

ExitProcess exit code
Success0
Interruption only130
Failure with errorExitCodethat code
Any other failure1

The API is identical across platforms — only the import changes. Pick the runner for your runtime:

// Node
import { NodeRuntime } from "@effect/platform-node"
NodeRuntime.runMain(program)
// Bun
import { BunRuntime } from "@effect/platform-bun"
BunRuntime.runMain(program)

runMain does not provide platform services itself. If your program uses file system, HTTP server, or other platform APIs, provide the corresponding layers (for example NodeServices.layer / BunServices.layer) before launching.

runMain accepts an optional configuration object:

import { NodeRuntime } from "@effect/platform-node"
import { Effect } from "effect"
const program = Effect.logInfo("hello")
NodeRuntime.runMain(program, {
// Disable the built-in failure logging if your app already centralizes
// error reporting (e.g. through your own observability layer).
disableErrorReporting: true,
// Provide custom finalization / exit-code logic at the process boundary.
teardown: (exit, onExit) => onExit(0)
})
  • disableErrorReporting — turn off the automatic Effect.logError emitted for unreported failures. It does not change the Exit, interruption behaviour, or the teardown exit-code rules.
  • teardown — supply your own Runtime.Teardown to control finalization and the process exit code; the default reports a non-zero code on failure.

You can also tag individual errors to control reporting and exit codes via the Runtime.errorReported and Runtime.errorExitCode markers, which lets specific error types opt out of logging or set a custom code.

Use runMain whenever an Effect is your process entrypoint — it is the right default for almost every standalone application. Reach for the lower-level Effect.run* functions only when you are embedding Effect inside an existing host that already owns the process lifecycle (a web framework handler, a test, an existing async function). For apps composed entirely of layers, pair runMain with Layer.launch.


The platform runners are thin wrappers around the core Runtime module (imported from "effect"). NodeRuntime.runMain and BunRuntime.runMain are each just one call to Runtime.makeRunMain that wires up process signal handlers and process.exit. Most applications never touch this layer — reach for it only when you are building a custom host (a new platform adapter, a test harness, a worker pool, an embedded runtime).

Builds a platform runMain from a setup callback. Your callback receives the already-forked fiber and a teardown function; it is responsible for observing the fiber and eventually calling teardown. This is exactly how NodeRuntime.runMain is implemented (it adds SIGINT/SIGTERM handlers and calls process.exit from the teardown’s onExit).

import { Effect, Runtime } from "effect"
const runMain = Runtime.makeRunMain(({ fiber, teardown }) => {
fiber.addObserver((exit) => {
teardown(exit, (code) => {
console.log(`finished with exit code ${code}`)
// a real host would call process.exit(code) here
})
})
})
runMain(Effect.log("booted"))
// => logs "booted", then "finished with exit code 0"

makeRunMain forks the effect for you. When disableErrorReporting is not set, it wraps the effect in Effect.tapCause so non-interruption failures whose squashed error is “reported” get logged via Effect.logError. It also installs a long keep-alive interval (cleared when the fiber completes) so the host process does not exit before the main fiber finishes.

The returned runner is dual, so it accepts both data-first and data-last forms:

import { Effect, Runtime } from "effect"
const runMain = Runtime.makeRunMain(({ fiber, teardown }) => {
fiber.addObserver((exit) => teardown(exit, () => {}))
})
// data-first: pass the effect directly
runMain(Effect.log("a"))
// data-last: pass options first, then apply to the effect (pipeable)
Effect.log("b").pipe(runMain({ disableErrorReporting: true }))

The type of a teardown function: (exit, onExit) => void. Given the program’s Exit, it computes a process exit code and hands it to onExit. Supply a custom one via the teardown option to override exit-code behaviour.

import { Exit, Runtime } from "effect"
const customTeardown: Runtime.Teardown = (exit, onExit) => {
if (Exit.isSuccess(exit)) {
onExit(0)
} else {
// exit code 2 for any failure instead of the default 1
onExit(2)
}
}
// Use with any runMain:
// NodeRuntime.runMain(program, { teardown: customTeardown })

The standard Teardown used when you do not pass one. It maps success to 0, interruption-only causes to 130, and other failures to the squashed error’s errorExitCode (falling back to 1). Call it directly when your custom teardown only needs to add behaviour around the default rules.

import { Exit, Runtime } from "effect"
const logExitCode = (exit: Exit.Exit<any, any>) =>
Runtime.defaultTeardown(exit, (code) => console.log(`Exit code: ${code}`))
logExitCode(Exit.succeed(42))
// => Exit code: 0
logExitCode(Exit.fail("error"))
// => Exit code: 1
logExitCode(Exit.interrupt(123))
// => Exit code: 130
import { Runtime } from "effect"
// A teardown that runs extra logging, then defers to the default rules.
const teardown: Runtime.Teardown = (exit, onExit) => {
console.log("shutting down...")
Runtime.defaultTeardown(exit, onExit)
}

A marker key you attach to an error to set the process exit code when that error reaches the default teardown. defaultTeardown reads it from the squashed cause, so it works through wrapped failures.

import { Data, Effect, Runtime } from "effect"
import { NodeRuntime } from "@effect/platform-node"
class ConfigError extends Data.TaggedError("ConfigError") {
readonly [Runtime.errorExitCode] = 78 // EX_CONFIG (sysexits.h)
}
// If the program fails with ConfigError, the process exits with code 78.
NodeRuntime.runMain(Effect.fail(new ConfigError()))
// => logs the error, process.exit(78)

Runtime.errorExitCode is also exported as a type (the string literal "~effect/Runtime/errorExitCode") for typing the marker property on custom error classes.

Reads the errorExitCode marker from an unknown value, returning 1 when it is absent or not a number. This is the function defaultTeardown uses to compute the failure exit code.

import { Runtime } from "effect"
Runtime.getErrorExitCode({ [Runtime.errorExitCode]: 42 })
// => 42
Runtime.getErrorExitCode(new Error("boom"))
// => 1
Runtime.getErrorExitCode("not an object")
// => 1

A marker key you attach to an error to control whether runMain logs it. Set it to false on errors that your application code has already reported, to avoid a duplicate log. Omitted or non-boolean values are treated as true (logged).

import { Data, Effect, Runtime } from "effect"
import { NodeRuntime } from "@effect/platform-node"
class AlreadyLogged extends Data.TaggedError("AlreadyLogged") {
readonly [Runtime.errorReported] = false
}
// Process exits with code 1, but runMain emits no log for this failure.
NodeRuntime.runMain(Effect.fail(new AlreadyLogged()))
// => process.exit(1), no error log

Like errorExitCode, Runtime.errorReported is also exported as a type (the literal "~effect/Runtime/errorReported") for typing the marker property. The marker only affects automatic logging — it never changes the Exit or the exit code.

Reads the errorReported marker from an unknown value, returning true when it is absent or not a boolean. This is what makeRunMain consults (on the squashed cause) before logging a failure.

import { Runtime } from "effect"
Runtime.getErrorReported({ [Runtime.errorReported]: false })
// => false
Runtime.getErrorReported(new Error("boom"))
// => true (no marker, so logged by default)
Runtime.getErrorReported(null)
// => true