Skip to content

Child Processes

Effect models running external commands as two pieces: a Command value that describes what to run, and the ChildProcessSpawner service that actually runs it. A Command is a plain, immutable description — building or composing one never starts a process — so you can construct, pipe, and reuse commands freely. These APIs live under effect/unstable/process, and the Node implementation comes from NodeServices.layer (or the narrower NodeChildProcessSpawner.layer).

import { NodeServices } from "@effect/platform-node"
import { Console, Context, Effect, Layer, Schema, Stream, String } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
// Wrap process failures in a domain error rather than leaking PlatformError.
class DevToolsError extends Schema.TaggedErrorClass<DevToolsError>()(
"DevToolsError",
{ cause: Schema.Defect }
) {}
class DevTools extends Context.Service<DevTools, {
readonly nodeVersion: Effect.Effect<string, DevToolsError>
readonly runLintFix: Effect.Effect<void, DevToolsError>
}>()("app/DevTools") {
static readonly layer = Layer.effect(
DevTools,
Effect.gen(function*() {
// Running a command requires a `ChildProcessSpawner`.
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
// `spawner.string` runs a command and collects ALL of its stdout into a
// single string. Ideal for short, finite commands.
const nodeVersion = spawner.string(
ChildProcess.make("node", ["--version"])
).pipe(
Effect.map(String.trim),
Effect.mapError((cause) => new DevToolsError({ cause }))
)
const runLintFix = Effect.gen(function*() {
// `spawner.spawn` returns a handle so you can stream output WHILE the
// process is still running. It adds a `Scope` requirement to manage
// the process lifecycle.
const handle = yield* spawner.spawn(
ChildProcess.make("pnpm", ["lint-fix"], {
env: { FORCE_COLOR: "1" }, // set env vars for the child
extendEnv: true // ...on top of the parent's env
})
)
// `handle.all` interleaves stdout and stderr as a byte Stream.
yield* handle.all.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Console.log(`[lint-fix] ${line}`))
)
// Wait for the process to finish and inspect its exit code.
const exitCode = yield* handle.exitCode
if (exitCode !== ChildProcessSpawner.ExitCode(0)) {
return yield* new DevToolsError({
cause: new Error(`lint-fix exited with ${exitCode}`)
})
}
}).pipe(
Effect.mapError((cause) =>
cause instanceof DevToolsError ? cause : new DevToolsError({ cause })
),
// `spawn` is scoped; `Effect.scoped` provides the scope and ensures the
// child process is cleaned up when the effect completes or fails.
Effect.scoped
)
return { nodeVersion, runLintFix } as const
})
).pipe(
// Provide the spawner from the Node platform services.
Layer.provide(NodeServices.layer)
)
}

ChildProcess.make builds a Command. It accepts a program name with an array of arguments, or a tagged-template form for shorthand. Arguments are passed directly to the OS — they are not interpreted by a shell — so values containing spaces or special characters are safe without quoting:

import { ChildProcess } from "effect/unstable/process"
// Explicit program + args (recommended; no shell quoting pitfalls).
const a = ChildProcess.make("git", ["commit", "-m", "a message with spaces"])
// Tagged-template shorthand for simple commands.
const b = ChildProcess.make`node --version`
// Configure the working directory, environment, and more via options.
const c = ChildProcess.make("npm", ["test"], {
cwd: "packages/core",
env: { CI: "true" },
extendEnv: true
})

When a command runs to completion and you want its output, the spawner offers convenience methods that run the command and return the result directly — no handle, no scope:

  • spawner.string(command) — all stdout as a single string.
  • spawner.lines(command) — stdout split into an array of lines.
  • spawner.exitCode(command) — just the exit code.
import { Effect } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
const changedFiles = Effect.fn("changedFiles")(function*(baseRef: string) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
// `lines` is perfect for line-oriented commands like `git diff`.
const files = yield* spawner.lines(
ChildProcess.make("git", ["diff", "--name-only", `${baseRef}...HEAD`])
)
return files.filter((file) => file.endsWith(".ts"))
})

ChildProcess.pipeTo connects the output of one command to the input of another, mirroring a shell pipe. The result is a single Command, so it runs as a real OS pipeline rather than buffering through your process:

import { Effect } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
const recentSubjects = Effect.gen(function*() {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
// Equivalent to: git log --pretty=format:%s -n 20 | head -n 5
const command = ChildProcess.make("git", [
"log",
"--pretty=format:%s",
"-n",
"20"
]).pipe(
ChildProcess.pipeTo(ChildProcess.make("head", ["-n", "5"]))
)
return yield* spawner.lines(command)
})

By default pipeTo connects stdout to stdin. Pass { from: "stderr" } or { from: "all" } to pipe a different stream.

For long-running commands — build watchers, log tailers, servers — you want to react to output as it arrives. spawner.spawn returns a ChildProcessHandle exposing:

  • handle.stdout, handle.stderr, handle.all — output as byte Streams.
  • handle.stdin — a Sink to write to the process input.
  • handle.exitCode — an effect that completes when the process exits.
  • handle.isRunning, handle.pid, and handle.kill for control.

You can also stream output without a handle using spawner.streamLines or spawner.streamString, which return a Stream directly:

import { Console, Effect, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
const tailBuild = Effect.gen(function*() {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
yield* spawner.streamLines(
ChildProcess.make("npm", ["run", "build", "--", "--watch"]),
{ includeStderr: true } // interleave stderr with stdout
).pipe(
Stream.runForEach((line) => Console.log(`[build] ${line}`))
)
})

Exit codes are branded with ExitCode to keep them distinct from ordinary numbers. Compare against ChildProcessSpawner.ExitCode(0) to check for success:

import { Effect } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
const typecheck = Effect.gen(function*() {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const code = yield* spawner.exitCode(
ChildProcess.make("tsc", ["--noEmit"])
)
return code === ChildProcessSpawner.ExitCode(0) ? "passed" : "failed"
})

Provide the spawner and run with NodeRuntime.runMain. Note how the program is written entirely against the abstract ChildProcessSpawner — only the layer mentions Node:

import { NodeRuntime } from "@effect/platform-node"
import { Effect } from "effect"
// `DevTools` from the first example provides its own layer.
const program = Effect.gen(function*() {
const tools = yield* DevTools
yield* Effect.log(`node=${yield* tools.nodeVersion}`)
}).pipe(Effect.provide(DevTools.layer))
NodeRuntime.runMain(program)

To consume command output with the full set of stream operators — buffering, batching, decoding — see the Streaming section.