The effect/unstable/process module provides an Effect-native API for working with child processes. Build commands using a compositional API, then execute them with streaming output and proper resource management.
Creating commands
Use ChildProcess.make to create command values that can be executed later.
import { ChildProcess } from "effect/unstable/process"
// Create a simple command
const nodeVersion = ChildProcess . make ( "node" , [ "--version" ])
// Template syntax for convenience
const echoCommand = ChildProcess . make `echo "hello world"`
// With options
const lsCommand = ChildProcess . make ( "ls" , [ "-la" ], {
cwd: "/tmp" ,
env: { FORCE_COLOR: "1" },
extendEnv: true // Merge with parent environment
})
Commands are values that describe what to execute. They don’t run until you spawn them.
Collecting output
The ChildProcessSpawner service provides methods for executing commands with different output strategies.
Collecting as string
Use spawner.string to collect the entire output as a string.
import { NodeServices } from "@effect/platform-node"
import { Effect , String } from "effect"
import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process"
const getNodeVersion = Effect . gen ( function* () {
const spawner = yield * ChildProcessSpawner . ChildProcessSpawner
const version = yield * spawner . string (
ChildProcess . make ( "node" , [ "--version" ])
). pipe (
Effect . map ( String . trim )
)
return version
}). pipe ( Effect . provide ( NodeServices . layer ))
Collecting as lines
Use spawner.lines for line-oriented output.
const getChangedFiles = Effect . gen ( function* ( baseRef : string ) {
const spawner = yield * ChildProcessSpawner . ChildProcessSpawner
const files = yield * spawner . lines (
ChildProcess . make ( "git" , [ "diff" , "--name-only" , ` ${ baseRef } ...HEAD` ])
)
return files . filter (( file ) => file . endsWith ( ".ts" ))
})
Streaming output
For long-running processes, use spawner.spawn to get a handle with streaming output.
import { NodeServices } from "@effect/platform-node"
import { Console , Effect , Stream } from "effect"
import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process"
const runLintFix = Effect . gen ( function* () {
const spawner = yield * ChildProcessSpawner . ChildProcessSpawner
// Spawn the process and get a handle
const handle = yield * spawner . spawn (
ChildProcess . make ( "pnpm" , [ "lint-fix" ], {
env: { FORCE_COLOR: "1" },
extendEnv: true
})
)
// Stream and process output
yield * handle . all . pipe (
Stream . decodeText (),
Stream . splitLines ,
Stream . runForEach (( line ) => Console . log ( `[lint-fix] ${ line } ` ))
)
// Check exit code
const exitCode = yield * handle . exitCode
if ( exitCode !== ChildProcessSpawner . ExitCode ( 0 )) {
return yield * Effect . fail ( new Error ( `lint-fix failed with exit code ${ exitCode } ` ))
}
}). pipe (
// spawner.spawn requires Scope for resource management
Effect . scoped ,
Effect . provide ( NodeServices . layer )
)
Process handles
Spawned processes provide several streams and properties:
interface ChildProcessHandle {
readonly stdout : Stream . Stream < Uint8Array , PlatformError > // Standard output
readonly stderr : Stream . Stream < Uint8Array , PlatformError > // Standard error
readonly all : Stream . Stream < Uint8Array , PlatformError > // Combined stdout/stderr
readonly exitCode : Effect . Effect < number , PlatformError > // Exit code
}
Piping commands
Compose pipelines using ChildProcess.pipeTo to connect commands.
import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process"
import { Effect } from "effect"
// Build a pipeline: git log | head -n 5
const recentCommits = ChildProcess . make ( "git" , [
"log" ,
"--pretty=format:%s" ,
"-n" ,
"20"
]). pipe (
ChildProcess . pipeTo ( ChildProcess . make ( "head" , [ "-n" , "5" ]))
)
// Execute the pipeline
const program = Effect . gen ( function* () {
const spawner = yield * ChildProcessSpawner . ChildProcessSpawner
const lines = yield * spawner . lines ( recentCommits )
return lines
})
Pipelines connect stdout of the first command to stdin of the second, just like shell pipes.
Building a service
Wrap common process operations in a service for reuse.
import { NodeServices } from "@effect/platform-node"
import { Console , Effect , Layer , Schema , ServiceMap , Stream , String } from "effect"
import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process"
export class DevToolsError extends Schema . TaggedErrorClass < DevToolsError >()( "DevToolsError" , {
cause: Schema . Defect
}) {}
export class DevTools extends ServiceMap . Service < DevTools , {
readonly nodeVersion : Effect . Effect < string , DevToolsError >
readonly recentCommitSubjects : Effect . Effect < ReadonlyArray < string >, DevToolsError >
readonly runLintFix : Effect . Effect < void , DevToolsError >
changedTypeScriptFiles ( baseRef : string ) : Effect . Effect < ReadonlyArray < string >, DevToolsError >
}>()( "docs/DevTools" ) {
static readonly layer = Layer . effect (
DevTools ,
Effect . gen ( function* () {
const spawner = yield * ChildProcessSpawner . ChildProcessSpawner
const nodeVersion = spawner . string (
ChildProcess . make ( "node" , [ "--version" ])
). pipe (
Effect . map ( String . trim ),
Effect . mapError (( cause ) => new DevToolsError ({ cause }))
)
const changedTypeScriptFiles = Effect . fn ( "DevTools.changedTypeScriptFiles" )(
function* ( baseRef : string ) {
yield * Effect . annotateCurrentSpan ({ baseRef })
const files = yield * spawner . lines (
ChildProcess . make ( "git" , [ "diff" , "--name-only" , ` ${ baseRef } ...HEAD` ])
). pipe (
Effect . mapError (( cause ) => new DevToolsError ({ cause }))
)
return files . filter (( file ) => file . endsWith ( ".ts" ))
}
)
const recentCommitSubjects = spawner . lines (
ChildProcess . make ( "git" , [ "log" , "--pretty=format:%s" , "-n" , "20" ]). pipe (
ChildProcess . pipeTo ( ChildProcess . make ( "head" , [ "-n" , "5" ]))
)
). pipe (
Effect . mapError (( cause ) => new DevToolsError ({ cause }))
)
const runLintFix = Effect . gen ( function* () {
const handle = yield * spawner . spawn (
ChildProcess . make ( "pnpm" , [ "lint-fix" ], {
env: { FORCE_COLOR: "1" },
extendEnv: true
})
). pipe (
Effect . mapError (( cause ) => new DevToolsError ({ cause }))
)
yield * handle . all . pipe (
Stream . decodeText (),
Stream . splitLines ,
Stream . runForEach (( line ) => Console . log ( `[lint-fix] ${ line } ` )),
Effect . mapError (( cause ) => new DevToolsError ({ cause }))
)
const exitCode = yield * handle . exitCode . pipe (
Effect . mapError (( cause ) => new DevToolsError ({ cause }))
)
if ( exitCode !== ChildProcessSpawner . ExitCode ( 0 )) {
return yield * new DevToolsError ({
cause: new Error ( `pnpm lint-fix failed with exit code ${ exitCode } ` )
})
}
}). pipe ( Effect . scoped )
return DevTools . of ({
nodeVersion ,
changedTypeScriptFiles ,
recentCommitSubjects ,
runLintFix
})
})
). pipe ( Layer . provide ( NodeServices . layer ))
}
// Usage
export const program = Effect . gen ( function* () {
const tools = yield * DevTools
const version = yield * tools . nodeVersion
yield * Effect . log ( `node= ${ version } ` )
const files = yield * tools . changedTypeScriptFiles ( "main" )
yield * Effect . log ( `Changed TS files: ${ files . length } ` )
}). pipe ( Effect . provide ( DevTools . layer ))
Key features
Compositional API Build commands as values, compose them with pipes, then execute when ready.
Streaming output Process output as it arrives using Effect’s Stream API for long-running commands.
Resource safety Automatic cleanup of process resources using Effect’s Scope.
Type-safe errors All errors are typed as PlatformError with full stack traces.
The process module requires a platform-specific ChildProcessSpawner implementation. Use NodeServices.layer for Node.js:
import { NodeServices } from "@effect/platform-node"
program . pipe (
Effect . provide ( NodeServices . layer )
)
The process module is in the unstable namespace. The API may change in future versions.
Use Effect.scoped when working with spawner.spawn to ensure proper cleanup of process resources.