effect-env
v0.4.1
Published
Typed, testable, policy-aware env management for Effect apps
Maintainers
Readme
effect-env — typed, testable, policy-aware env for Effect apps
A TypeScript library for managing environment variables with type safety, schema validation, and security features using Effect.
Features
- Type-safe: Full TypeScript inference from
@effect/schemadefinitions - Schema-driven: Validation, transformation, and type inference in one place
- Server/Client separation: Prevent secrets from leaking to client bundles (t3-env style)
- Prefix enforcement: Automatic validation that client vars use correct prefix
- Testable: Built on Effect layers, easy to mock in tests
- Secure: Redaction helpers for safe logging, production-safe overrides
- Effect-native: Full composability with Effect ecosystem
Installation
npm install effect-env
# or
bun add effect-envQuickstart
Simple Usage (Single Schema)
import { Schema as S } from "effect"
import { createSimpleEnv, EnvService } from "effect-env"
// 1. Define your schema
const env = createSimpleEnv(
S.Struct({
NODE_ENV: S.Literal("development", "production", "test"),
PORT: S.NumberFromString,
DATABASE_URL: S.String
}),
process.env
)
// 2. Use in your program
const program = Effect.gen(function* () {
const envService = yield* EnvService
const port = yield* envService.get("PORT") // typed as number
const dbUrl = yield* envService.get("DATABASE_URL") // typed as string
return { port, dbUrl }
})
// 3. Run with the env layer
Effect.runPromise(Effect.provide(program, env))Server/Client Separation (t3-env style)
import { Schema as S } from "effect"
import { createEnv, EnvService } from "effect-env"
const env = createEnv({
// Server-only variables (never exposed to client)
server: S.Struct({
DATABASE_URL: S.String,
API_SECRET: S.String,
JWT_SECRET: S.String
}),
// Client-safe variables (sent to browser)
// Must start with the clientPrefix!
client: S.Struct({
PUBLIC_API_URL: S.String,
PUBLIC_APP_NAME: S.String
}),
clientPrefix: "PUBLIC_",
runtimeEnv: process.env
})
const program = Effect.gen(function* () {
const envService = yield* EnvService
// All typed correctly and safely
const apiUrl = yield* envService.get("PUBLIC_API_URL")
const secret = yield* envService.get("API_SECRET")
return { apiUrl, secret }
})
Effect.runPromise(Effect.provide(program, env))Schema Transformations
Use Effect Schema's built-in transformations:
const env = createSimpleEnv(
S.Struct({
PORT: S.NumberFromString, // String → Number
DEBUG: S.BooleanFromString, // "true" | "false" → boolean
CONFIG: S.parseJson(S.Unknown), // JSON string → object
LOG_LEVEL: S.optionalWith(S.String, {
default: () => "info" // Defaults
})
})
)Testing with Override
it("uses overridden PORT", async () => {
const program = Effect.gen(function* () {
const env = yield* EnvService
// Override just for this effect
return yield* env.withOverride("PORT", "9000")(
env.get("PORT")
)
})
const result = await Effect.runPromise(Effect.provide(program, env))
expect(result).toBe("9000")
})
// Note: withOverride is disabled in production (process.env.NODE_ENV === "production")Validation
Validate environment at startup for clear error reporting:
import { validate } from "effect-env"
const envSchema = S.Struct({
PORT: S.NumberFromString,
API_KEY: S.String
})
// In dev/test: prints friendly table and continues
// In production: fails fast with exit code
await Effect.runPromise(validate(envSchema, process.env))Sample validation report:
Key | Status | Details
-------------|--------------|--------
API_KEY | missing | required but not provided
PORT | invalid | Expected number, actual "abc"Redaction
Safely log environment variables without exposing secrets:
import { redact } from "effect-env"
const safeEnv = redact(process.env)
// { NODE_ENV: "development", API_KEY: "***", DB_PASSWORD: "***" }
// Custom matchers
const safeEnv = redact(process.env, {
extra: ["SESSION_ID", /^CUSTOM_/]
})Redacts keys containing (case-insensitive): key, token, secret, password, pwd, private, bearer, api, auth.
Testing
Test with createSimpleEnv or createEnv using a test record:
import { createSimpleEnv, EnvService } from "effect-env"
import { describe, it, expect } from "vitest"
const testEnv = createSimpleEnv(
S.Struct({
PORT: S.NumberFromString,
DATABASE_URL: S.String
}),
{
PORT: "3000",
DATABASE_URL: "postgres://localhost"
}
)
it("reads typed env vars", async () => {
const program = Effect.gen(function* () {
const env = yield* EnvService
return yield* env.get("PORT") // 3000
})
const result = await Effect.runPromise(Effect.provide(program, testEnv))
expect(result).toBe(3000)
})Or with withOverride in dev/test:
const program = Effect.gen(function* () {
const env = yield* EnvService
return yield* env.withOverride("PORT", "8080")(env.get("PORT"))
})
const result = await Effect.runPromise(Effect.provide(program, env))
expect(result).toBe("8080")API Reference
createEnv(config)
Create a typed environment layer with server/client separation.
createEnv({
server: S.Schema<Server>, // Server-only variables
client: S.Schema<Client>, // Client-safe variables
clientPrefix: "PUBLIC_", // Prefix for client vars
runtimeEnv?: Record<string, string>, // Default: process.env
skipValidation?: boolean, // Default: false
onValidationError?: (error) => void // Custom error handler
}): Layer<Env<Server & Client>>createSimpleEnv(schema, runtimeEnv?, skipValidation?, onValidationError?)
Create a simple typed environment layer (no server/client separation).
createSimpleEnv(
schema: S.Schema<T>, // Environment schema
runtimeEnv?: Record<string, string>, // Default: process.env
skipValidation?: boolean, // Default: false
onValidationError?: (error) => void // Custom error handler
): Layer<Env<T>>EnvService Methods
get<K>(key: K): Effect<E[K], EnvError>- Get typed valuerequire<K>(key: K): Effect<NonNullable<E[K]>, MissingVarError>- Require non-nullall(): Effect<E>- Get all valueswithOverride<K>(key: K, value: string)(fa: Effect<A>): Effect<A>- Override for testing (disabled in production)
Utilities
validate(schema: S.Schema<E>, source: Record<string, string | undefined>, opts?): Effect<void, ValidationError>- Startup validationredact(record: Record<string, string | undefined>, opts?): Record<string, string | undefined>- Safe logging
Legacy APIs (deprecated)
fromProcess,fromDotenv,fromRecord- UsecreateSimpleEnvinsteadmakeEnvSchema- Wrapper no longer needed with direct Schema usage
Notes
- Security: Never log raw env vars. Use
redact()for safe logging. - Type Inference: All types flow through the schema; no additional type annotations needed.
- Defaults: Use
S.optionalWith(S.String, { default: () => "value" })for defaults. - Errors: Clear messages include key names and value snippets for debugging.
- Production: Validation fails fast;
withOverrideis disabled. - Server/Client: Client variables MUST start with the configured prefix (default
"PUBLIC_").
Contributing
PRs welcome! Run npm test and npm run typecheck before submitting.
License
MIT
