@cfast/env
Overview
Section titled “Overview”Every Cloudflare Worker has bindings — D1 databases, KV namespaces, R2 buckets, secrets, and environment variables. By default they are loosely typed, and misconfiguration surfaces as a runtime crash buried in a stack trace.
@cfast/env provides a single schema definition that produces TypeScript types and runtime validation in one place. Missing or misconfigured bindings are caught at Worker startup, before any request is processed, so you never find out about a missing secret at 3am.
Installation
Section titled “Installation”pnpm add @cfast/envNo peer dependencies required.
Quick Setup
Section titled “Quick Setup”Declare your bindings, initialize once in the Worker entry point, and use the typed result everywhere:
import { defineEnv } from "@cfast/env";
const env = defineEnv({ DB: { type: "d1" }, CACHE: { type: "kv" }, UPLOADS: { type: "r2" }, MAILGUN_API_KEY: { type: "secret" }, APP_URL: { type: "var", default: "http://localhost:8787" },});
export default { async fetch(request, rawEnv) { env.init(rawEnv); const { DB, MAILGUN_API_KEY, APP_URL } = env.get(); // ^-- D1Database ^-- string ^-- string },};To derive a reusable type for function signatures:
export type Env = ReturnType<typeof env.get>;Core Concepts
Section titled “Core Concepts”Two-Phase Lifecycle
Section titled “Two-Phase Lifecycle”The env instance has two methods: init() and get(). Calling init(rawEnv) validates every binding against the schema. If any fail, it throws an EnvError listing all failures at once so you can fix everything in a single pass. After a successful init(), subsequent calls are a no-op — the first valid environment wins.
get() returns the validated, fully typed object. It throws if init() has not been called successfully.
Binding Types
Section titled “Binding Types”Each binding declaration maps to a concrete Cloudflare type at compile time and uses duck-type validation at runtime:
| Type | TypeScript Type | Validation |
|---|---|---|
d1 | D1Database | Object with .prepare() method |
kv | KVNamespace | Object with .get() and .put() methods |
r2 | R2Bucket | Object with .put() and .head() methods |
queue | Queue | Object with .send() method |
durable-object | DurableObjectNamespace | Object with .get() and .idFromName() methods |
service | Fetcher | Object with .fetch() method |
secret | string | Non-empty string |
var | string | String (empty allowed, supports defaults) |
The secret type requires a non-empty string and does not allow defaults — it is intended for values set via wrangler secret put. The var type supports defaults and custom validation callbacks for configuration set in wrangler.toml.
Environment-Aware Defaults
Section titled “Environment-Aware Defaults”Variables can have different defaults per environment. The current environment is determined by a reserved ENVIRONMENT binding in rawEnv (valid values: "development", "staging", "production"):
const env = defineEnv({ APP_URL: { type: "var", default: { development: "http://localhost:8787", staging: "https://staging.myapp.com", production: "https://myapp.com", }, },});Set the environment in wrangler.toml:
[vars]ENVIRONMENT = "production"
[env.staging.vars]ENVIRONMENT = "staging"Common Patterns
Section titled “Common Patterns”Cloudflare Bindings vs External API Keys
Section titled “Cloudflare Bindings vs External API Keys”type: "secret" is not Cloudflare-specific — it just declares “this binding must be a non-empty string read from the environment”. Use it for both Cloudflare-managed secrets (set via wrangler secret put) and plain external API keys for third-party services like Stripe, Nutritionix, or OpenAI. Use type: "var" for non-sensitive identifiers (app IDs, region codes, feature flags).
A complete example with both Cloudflare resource bindings and external API credentials:
import { defineEnv } from "@cfast/env";
export const env = defineEnv({ // --- Cloudflare resource bindings --- DB: { type: "d1" as const }, CACHE: { type: "kv" as const }, UPLOADS: { type: "r2" as const },
// --- External API secrets (set via `wrangler secret put`) --- STRIPE_SECRET_KEY: { type: "secret" as const }, NUTRITIONIX_API_KEY: { type: "secret" as const },
// --- Plain non-secret config (set in wrangler.toml [vars]) --- NUTRITIONIX_APP_ID: { type: "var" as const }, APP_URL: { type: "var" as const, default: "http://localhost:8787" },});The matching wrangler.toml — Cloudflare bindings are declared as resources, plain vars live under [vars], and secrets are intentionally absent (they go through wrangler secret put instead):
name = "my-app"main = "workers/app.ts"
[[d1_databases]]binding = "DB"database_name = "my-app-db"database_id = "..."
[[kv_namespaces]]binding = "CACHE"id = "..."
[[r2_buckets]]binding = "UPLOADS"bucket_name = "my-app-uploads"
[vars]NUTRITIONIX_APP_ID = "abc123"APP_URL = "https://my-app.workers.dev"For local development, secrets go in .dev.vars (gitignored). Wrangler injects them into rawEnv exactly like real secrets:
# .dev.vars -- never commit this fileSTRIPE_SECRET_KEY=sk_test_...NUTRITIONIX_API_KEY=...For production, push secrets via wrangler:
wrangler secret put STRIPE_SECRET_KEYwrangler secret put NUTRITIONIX_API_KEYThen read them like any other binding — everything is typed and validated at startup:
import { env } from "../app/env";
export default { async fetch(request, rawEnv) { env.init(rawEnv); const { DB, STRIPE_SECRET_KEY, NUTRITIONIX_API_KEY, NUTRITIONIX_APP_ID } = env.get(); // DB: D1Database // STRIPE_SECRET_KEY: string (from `wrangler secret put` or .dev.vars) // NUTRITIONIX_API_KEY: string (same) // NUTRITIONIX_APP_ID: string (from wrangler.toml [vars]) },};If any secret is missing, env.init(rawEnv) throws an EnvError listing every problem at once — you find out at deploy time, not at 3am when a request hits the missing key.
Optional Bindings
Section titled “Optional Bindings”Any binding type supports optional: true. When a binding is missing and marked optional, it resolves to undefined instead of throwing an EnvError:
const env = defineEnv({ DB: { type: "d1" }, NUTRITIONIX_API_KEY: { type: "secret", optional: true }, FEATURE_FLAGS_KV: { type: "kv", optional: true },});
env.init(rawEnv);const { DB, NUTRITIONIX_API_KEY, FEATURE_FLAGS_KV } = env.get();// ^-- string | undefined ^-- KVNamespace | undefinedThe TypeScript return type automatically becomes T | undefined for optional bindings. If a binding has both optional: true and a default, the default value is used when the binding is missing — it never resolves to undefined.
Custom Validation on Variables
Section titled “Custom Validation on Variables”Use the validate callback to constrain variable values beyond simple type checks:
const env = defineEnv({ LOG_LEVEL: { type: "var", default: "info", validate: (v) => ["debug", "info", "warn", "error"].includes(v), }, APP_URL: { type: "var", default: "http://localhost:8787", validate: (v) => v.startsWith("http"), },});Error Handling at Startup
Section titled “Error Handling at Startup”EnvError collects all validation failures so you can address them together:
import { EnvError } from "@cfast/env";
try { env.init(rawEnv);} catch (e) { if (e instanceof EnvError) { console.error(e.message); // @cfast/env: 2 binding error(s): // - DB: Missing required D1 binding 'DB'. Check your wrangler.toml. // - API_KEY: Missing required secret 'API_KEY'. Check your wrangler.toml.
for (const err of e.errors) { console.error(err.key, err.message); } }}Passing Env to Other Packages
Section titled “Passing Env to Other Packages”Other cfast packages accept the validated env object directly. This is the primary integration point — @cfast/env feeds typed bindings into @cfast/db, @cfast/auth, and @cfast/storage:
env.init(rawEnv);const { DB } = env.get();const db = createDb({ d1: DB, schema, permissions, user });When using @cfast/core, the env schema is passed directly to createApp() and initialization is handled automatically via app.init(rawEnv).