Defining your schema

Put env.ts in place, declare field types, and fail fast with a typed env object.

Edit on GitHub

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.

MomentWhat runs
Module loadarkenv() validates the current env source (default process.env)
Next.js / Nuxt buildFramework wrappers validate during config or module setup
Vite / Bun buildPlugins validate in the bundler pipeline
Client bundlePublic 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 arktype
pnpm add @arkenv/core arktype
yarn add @arkenv/core arktype
bun install @arkenv/core arktype
./env.ts
import  from "@arkenv/core";

export const  = ({
  : "string.host = 'localhost'",
  : "number.port = 3000",
  : "'development' | 'production' | 'test' = 'development'",
});
npm install @arkenv/standard zod
pnpm add @arkenv/standard zod
yarn add @arkenv/standard zod
bun install @arkenv/standard zod
./env.ts
import  from "@arkenv/standard";
import * as  from "zod";

export const  = ({
  : .().("localhost"),
  : .().(0).(65535).(3000),
  : 
    .(["development", "production", "test"])
    .("development"),
});

Import that object from application code:

./src/server.ts
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:

env.ts
.env
.env.example

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.

./env.ts
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.

./env.ts
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.

KeywordMeaning
string.hostAn IP address or "localhost"
number.portAn integer from 0 to 65535
./env.ts
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.

./env.ts
import , { type  } from "@arkenv/core";

const  = {
  : "string",
  : "number.port = 3000",
} as ;

export const  = ();
export type  = <typeof >;

.; // number
./env.ts
import  from "@arkenv/standard";
import * as  from "zod";

const  = {
  : .(),
  : .().(0).(65535).(3000),
};

export const  = ();
export type  = typeof ;

.; // number
InputResult
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:

./env.ts
import , {  } from "@arkenv/core";

export const  = ({
  : "number.port = 3000",
  : ("string[]").(() => []),
  : ("string").(() => .()),
});
./env.ts
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:

./env.ts
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:

PackageYou supplyShared with both engines
@arkenv/coreArkType DSL strings or type() definitionsarkenv() options, ArkEnvError, fail-fast boot, framework plugins
@arkenv/standardA map of Standard Schema validators (Zod, Valibot, …)Same, plus toJsonSchema

See Validators for packaging and peer-dependency differences.

Next steps