Effect equivalents of Go's slog.NewMultiHandler plus the scoped file logger pattern. The Go slog.NewMultiHandler and defer closeFunc() versions are in Foundations.
Pattern 1: Logger.zip
Direct slog.NewMultiHandler equivalent. Each logger fires for every log line.
// effect-utils/packages/@overeng/tui-react/src/effect/TuiLogger.ts:187-196
const layer = logToConsole === true
? Layer.merge(
Logger.replace(Logger.defaultLogger, Logger.zip(Logger.defaultLogger, tuiLogger)),
Logger.minimumLogLevel(minLevel),
)
: Layer.merge(
Logger.replace(Logger.defaultLogger, tuiLogger),
Logger.minimumLogLevel(minLevel),
)
Logger.zip(defaultLogger, tuiLogger) ↔ slog.New(slog.NewMultiHandler(handlerA, handlerB)). Output type is the zip of the two output types. Both loggers receive every message.
Pattern 2: Logger.layer([...])
Array-form API. mergeWithExisting: false replaces the default logger entirely instead of appending.
// t3code/apps/server/src/serverLogger.ts:8-16
export const ServerLoggerLive = Effect.gen(function* () {
const config = yield* ServerConfig;
const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], {
mergeWithExisting: false,
});
return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
}).pipe(Layer.unwrap);
Pattern 3: Scoped file logger with acquireRelease
Go closeFunc pattern, but structured by the Layer's scope. File handle is opened, attached to the logger, closed on Layer finalization.
// effect-utils/packages/@overeng/utils/src/node/FileLogger.ts:56-76
export const makeFileLogger = ({ logFilePath, threadName, colors }: MakeFileLoggerOptions) =>
Layer.unwrapScoped(
Effect.gen(function* () {
yield* Effect.sync(() => fs.mkdirSync(path.dirname(logFilePath), { recursive: true }))
const logFile = yield* Effect.acquireRelease(
Effect.sync(() => fs.openSync(logFilePath, 'a', 0o666)),
(fd) => Effect.sync(() => fs.closeSync(fd)),
)
return Logger.replace(
Logger.defaultLogger,
prettyLoggerTty({
colors: colors ?? false,
stderr: false,
formatDate: (date) => `${defaultDateFormat(date)} ${threadName ?? ''}`,
onLog: (str) => fs.writeSync(logFile, str),
}),
)
}),
)
Effect.acquireRelease ties cleanup to the Layer's scope. Combine with Logger.zip (or Logger.layer([...])) when you want console + file:
const fileLogger = makeFileLogger({ logFilePath: '/var/log/app.log' });
const consoleLogger = Logger.replace(Logger.defaultLogger, Logger.prettyLogger());
yield* myProgram.pipe(Effect.provide(Logger.zip(consoleLogger, fileLogger)));
When to use which
| Pattern | Reach for it when |
|---|---|
Logger.zip(a, b) |
Two loggers, both need to keep their identity, you want the zip output type |
Logger.layer([a, b, c], { mergeWithExisting: false }) |
Three or more destinations, or you're building a server logger with mixed transports |
makeFileLogger + Logger.zip |
You need a file and stdout, with the file handle scoped to the program |