Defining your schema
Put env.ts in place, declare field types, and fail fast with a typed env object.
ArkEnv is designed around one schema file and one typed env object.
ArkEnv proves the process has the configuration it claims before
application code runs, whether you use ArkType, Zod, or another Standard
Schema validator.
Fail fast
Calling arkenv() parses the configured source immediately. Missing
required keys or values that fail the schema throw
ArkEnvError at
the call site.
| Moment | What runs |
|---|---|
| Module load | arkenv() validates the current env source (default process.env) |
| Next.js / Nuxt build | Framework wrappers validate during config or module setup |
| Vite / Bun build | Plugins validate in the bundler pipeline |
| Client bundle | Public keys are already validated at build time; server secrets are stripped or guarded |
Framework adapters validate in builds by default. Only keys you declare
appear on the returned object (onUndeclaredKey defaults to
"delete"). env is a closed set of known fields, not leftover process
variables.
Where env.ts lives
Install either engine, then create env.ts next to the code that boots
first. Root of the app or package is common; src/env.ts is fine when
the rest of your entrypoints already live under src/.
npm install @arkenv/core arktypepnpm add @arkenv/core arktypeyarn add @arkenv/core arktypebun install @arkenv/core arktypeimport from "@arkenv/core";
export const = ({
: "string.host = 'localhost'",
: "number.port = 3000",
: "'development' | 'production' | 'test' = 'development'",
});npm install @arkenv/standard zodpnpm add @arkenv/standard zodyarn add @arkenv/standard zodbun install @arkenv/standard zodimport from "@arkenv/standard";
import * as from "zod";
export const = ({
: .().("localhost"),
: .().(0).(65535).(3000),
:
.(["development", "production", "test"])
.("development"),
});Import that object from application code:
import { env } from "../env";
console.log(`listening on ${env.HOST}:${env.PORT}`);Do not read process.env or import.meta.env directly
Reading process.env or import.meta.env skips ArkEnv. Import { env }
so public keys stay typed and coerced and server secrets stay out of the
client bundle. That surface is canonical across Node, Next.js, Nuxt,
Vite, and Bun.
Already on ArkType?
ArkType implements Standard Schema, but if you already depend on ArkType
stay on @arkenv/core rather than wrapping it through
@arkenv/standard. See
Validators.
What belongs in the schema
Put every variable your process reads into the schema: secrets, public keys, tunables, and platform vars you rely on.
- Required secrets (
DATABASE_URL,API_KEY) - Tunables with defaults (
PORT,LOG_LEVEL) - Framework public keys (
NEXT_PUBLIC_*,VITE_*,NUXT_PUBLIC_*,BUN_PUBLIC_*) - Hosting provider system variables (
VERCEL_ENV,CONTEXT, …) when you use them
The ArkEnv CLI and hosting presets can inject the provider fields for you. You can still declare them by hand.
A single env.ts is the default layout for Node apps and
flat layout
frameworks:
Keep .env.example in sync with the schema so new contributors know
which keys to set.
Strings, numbers, booleans, enums
Your schema is a map from env key names to validators.
@arkenv/core uses the ArkType DSL (and ArkEnv keywords).
@arkenv/standard accepts Zod, Valibot, and other
Standard Schema validators. Pick one
stack per project; both paths produce a typed env object.
Coercion and parsing covers how strings become numbers and booleans. This section is about declaring the shape.
import from "@arkenv/core";
export const = ({
: "string",
: "string = 'My App'",
: "string | undefined",
: "number.port = 3000",
: "number.integer = 3",
: "boolean = false",
: "'development' | 'production' | 'test' = 'development'",
: "'debug' | 'info' | 'warn' | 'error' = 'info'",
});A bare string requires the key. Append = 'value' for a default.
string | undefined accepts a missing key. An empty string (NOTE=)
still counts as present unless you set
emptyAsUndefined.
import from "@arkenv/standard";
import * as from "zod";
export const = ({
: .(),
: .().("My App"),
: .().(),
: .().(0).(65535).(3000),
: .().(3),
: .().(false),
:
.(["development", "production", "test"])
.("development"),
: .(["debug", "info", "warn", "error"]).("info"),
});Pass each field's schema as the map value for Valibot and other
Standard Schema libraries. Valibot:
@arkenv/standard/valibot.
Package: @arkenv/standard.
ArkEnv keywords (ArkType)
@arkenv/core adds env-oriented keywords on top of
ArkType's primitives. They are not
available on @arkenv/standard.
| Keyword | Meaning |
|---|---|
string.host | An IP address or "localhost" |
number.port | An integer from 0 to 65535 |
import from "@arkenv/core";
export const = ({
: "string.host = 'localhost'",
: "number.port = 3000",
});Full details: keywords.
Typesafety
The schema is the single source of truth for runtime checks and TypeScript types.
import , { type } from "@arkenv/core";
const = {
: "string",
: "number.port = 3000",
} as ;
export const = ();
export type = <typeof >;
.; // numberimport from "@arkenv/standard";
import * as from "zod";
const = {
: .(),
: .().(0).(65535).(3000),
};
export const = ();
export type = typeof ;
.; // number| Input | Result |
|---|---|
ArkType declarative map ({ PORT: "number" }) | Infer<typeof schema> from @arkenv/core |
Compiled type({ ... }) | typeof Env.infer / Infer<typeof Env> |
| Standard Schema field map (Zod, Valibot, …) | typeof env after arkenv(schema), or each validator's own infer helper |
Typesafety holds when application code imports env and validation runs
at boot or build. Casting process.env, or skipping the import, bypasses
it. Types do not load .env files.
Flat layouts may expose server key names to client TypeScript while blocking values. Strict layouts keep server modules out of the client graph. See Client vs. server.
Richer field definitions
For transforms, function defaults, or reuse across files:
import , { } from "@arkenv/core";
export const = ({
: "number.port = 3000",
: ("string[]").(() => []),
: ("string").(() => .()),
});import from "@arkenv/standard";
import * as from "zod";
export const = ({
: .().(0).(65535).(3000),
: .().(() => .()),
});See
Transforms
for morph vs Zod .transform, and
reusing schemas
when the whole schema should be shared.
Custom env source
By default arkenv() reads process.env. Pass a custom record for
tests, Workers bindings, or any host that injects env outside
process.env:
import from "@arkenv/core";
export const = (
{ : "number.port" },
{ : { : "3000" } },
);Framework plugins (Next.js, Nuxt, Vite, Bun) wire the usual sources for
you. See options for coerce, arrayFormat,
emptyAsUndefined, and safe.
What ArkEnv owns vs your schema library
ArkEnv loads the input record, optionally coerces strings, then hands the result to your schema library. Field rules always come from that library:
| Package | You supply | Shared with both engines |
|---|---|---|
@arkenv/core | ArkType DSL strings or type() definitions | arkenv() options, ArkEnvError, fail-fast boot, framework plugins |
@arkenv/standard | A map of Standard Schema validators (Zod, Valibot, …) | Same, plus toJsonSchema |
See Validators for packaging and peer-dependency differences.