Environment Variables

Type-safe environment variable validation with descriptive boot-time errors.

Overview

@tekir/env wraps envalid to give your application a single, validated snapshot of every environment variable it needs. If a required variable is missing or fails its validator, tekir throws a clear error at boot time, never during a live request.

bun add @tekir/env

The env.ts File

By convention your project keeps a single env.ts at the project root. It reads from .env (and the real process environment), validates every value, and exports a typed, frozen object. Import it anywhere via #env.

Tell tekir() to load the file at boot by passing envFile: 'env.ts'. Without that option, tekir does not import any env file: it leaves the path alone, which is what you want when an unrelated script in the project happens to be named env.ts.

.env
NODE_ENV=development
APP_NAME=My App
APP_KEY=           # run: tekir generate:key
HOST=0.0.0.0
PORT=3000
DB_PATH=./database/app.sqlite
LOG_LEVEL=info
env.ts
import { defineEnv, str, port, host } from '@tekir/env'

export default defineEnv({
  NODE_ENV:  str({ choices: ['development', 'production', 'test'], default: 'development' }),
  APP_NAME:  str({ default: 'tekir' }),
  APP_KEY:   str({ default: 'change-me-in-production' }),
  HOST:      host({ default: '0.0.0.0' }),  // '0.0.0.0' = all interfaces, 'localhost' = local only
  PORT:      port({ default: 3000 }),
  DB_PATH:   str({ default: ':memory:' }),
  LOG_LEVEL: str({ default: 'info' })
})

Set up the #env import alias in your tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "#env": ["./env.ts"]
    }
  }
}

If a required variable (one without a default) is missing, tekir prints a structured error and exits before any route is ever registered:

# Example: if a required variable (no default) is missing from .env
# defineEnv({ DB_PASSWORD: str() })  ← no default = required
#
# Output:
  Invalid env vars:
    DB_PASSWORD  Missing required environment variable

defineEnv()

defineEnv(schema) accepts a record of envalid validator calls, passes process.env through cleanEnv, and returns a frozen, fully-typed object. Call it exactly once, at the top of env.ts, and export the result as the default export.

env.ts
import { defineEnv, str, port } from '@tekir/env'

export default defineEnv({
  APP_KEY: str(),
  PORT:    port({ default: 3000 })
})

The return type is automatically inferred from the schema, env.PORT is number, env.APP_KEY is string, and so on. Accessing a key not in the schema throws at runtime (via envalid's proxy).

Built-in Validators

All validators accept an optional options object with at least a default property. Omitting default makes the variable required.

import { defineEnv, str, num, bool, port, url, email, json, host } from '@tekir/env'

export default defineEnv({
  // str: any string value (required if no default)
  APP_KEY:   str(),
  APP_NAME:  str({ default: 'tekir' }),

  // num: parsed to a JavaScript number
  MAX_RETRIES: num({ default: 3 }),

  // bool: accepts "true"/"false"/"1"/"0" → boolean
  DEBUG: bool({ default: false }),

  // port: integer 1–65535
  PORT: port({ default: 3000 }),

  // url: must be a valid URL string
  REDIS_URL: url({ default: 'redis://localhost:6379' }),

  // email: must be a valid e-mail address
  MAIL_FROM: email({ default: '[email protected]' }),

  // json: parsed with JSON.parse, returns the object/array
  FEATURE_FLAGS: json<{ darkMode: boolean }>({ default: { darkMode: false } }),

  // host: hostname or IP address (no protocol, no port)
  DB_HOST: host({ default: 'localhost' })
})

Restricting values with choices

Pass a choices array to any str() validator to restrict the accepted values to a fixed set. An out-of-range value is treated as invalid at boot.

import { defineEnv, str } from '@tekir/env'

export default defineEnv({
  LOG_LEVEL: str({ choices: ['debug', 'info', 'warn', 'error'], default: 'info' }),
  NODE_ENV:  str({ choices: ['development', 'production', 'test'] })
})

Custom Validators

Use makeValidator(parseFn) to create a validator for any value that the built-ins do not cover. The parse function receives the raw string and should either return the coerced value or throw an error.

env.ts
import { defineEnv, makeValidator } from '@tekir/env'

// makeValidator wraps a parsing function and returns an envalid validator.
// Throw to signal an invalid value.
const hexColor = makeValidator<string>((input) => {
  if (/^#[0-9a-fA-F]{6}$/.test(input)) return input
  throw new Error(`Expected a hex color (#rrggbb), got: ${input}`)
})

const positiveInt = makeValidator<number>((input) => {
  const n = parseInt(input, 10)
  if (isNaN(n) || n <= 0) throw new Error('Must be a positive integer')
  return n
})

export default defineEnv({
  BRAND_COLOR:  hexColor({ default: '#3b82f6' }),
  WORKER_COUNT: positiveInt({ default: 4 })
})

Reading Values

Import the env object from #env and access properties directly. Every property is fully typed based on the validator used in defineEnv():

// Import the typed env object via the #env import map
import env from '#env'

// Access properties directly: fully typed
const host = env.HOST            // string ('0.0.0.0' or 'localhost')
const port = env.PORT            // number
const key  = env.APP_KEY         // string

Use it in config files to wire environment values into your application configuration:

config/app.ts
import env from '#env'

export default {
  name: env.APP_NAME,
  host: env.HOST,
  port: env.PORT,
  key:  env.APP_KEY,
  env:  env.NODE_ENV
}

Or anywhere in your application code:

core/controllers/health_controller.ts
import env from '#env'
import type { HttpContext } from '@tekir/core'

export function show({ response }: HttpContext) {
  return response.ok({
    status: 'ok',
    env: env.NODE_ENV,
    debug: env.DEBUG
  })
}