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) )}Describing a command
Section titled “Describing a command”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})Collecting output
Section titled “Collecting output”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 singlestring.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"))})Pipelines
Section titled “Pipelines”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.
Streaming a running process
Section titled “Streaming a running process”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 byteStreams.handle.stdin— aSinkto write to the process input.handle.exitCode— an effect that completes when the process exits.handle.isRunning,handle.pid, andhandle.killfor 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}`)) )})Inspecting exit codes
Section titled “Inspecting exit codes”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"})Running it
Section titled “Running it”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.