Skip to content

PaulJPhilp/effect-env

Repository files navigation

effect-env — typed, testable, policy-aware env for Effect apps

npm version GitHub License: MIT

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/schema definitions
  • 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-env

Quickstart

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 value
  • require<K>(key: K): Effect<NonNullable<E[K]>, MissingVarError> - Require non-null
  • all(): Effect<E> - Get all values
  • withOverride<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 validation
  • redact(record: Record<string, string | undefined>, opts?): Record<string, string | undefined> - Safe logging

Legacy APIs (deprecated)

  • fromProcess, fromDotenv, fromRecord - Use createSimpleEnv instead
  • makeEnvSchema - 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; withOverride is 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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors