Skip to content

@cfast/env

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.

Terminal window
pnpm add @cfast/env

No peer dependencies required.

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>;

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.

Each binding declaration maps to a concrete Cloudflare type at compile time and uses duck-type validation at runtime:

TypeTypeScript TypeValidation
d1D1DatabaseObject with .prepare() method
kvKVNamespaceObject with .get() and .put() methods
r2R2BucketObject with .put() and .head() methods
queueQueueObject with .send() method
durable-objectDurableObjectNamespaceObject with .get() and .idFromName() methods
serviceFetcherObject with .fetch() method
secretstringNon-empty string
varstringString (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.

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"

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:

app/env.ts
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 file
STRIPE_SECRET_KEY=sk_test_...
NUTRITIONIX_API_KEY=...

For production, push secrets via wrangler:

Terminal window
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put NUTRITIONIX_API_KEY

Then read them like any other binding — everything is typed and validated at startup:

workers/app.ts
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.

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 | undefined

The 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.

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"),
},
});

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);
}
}
}

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).