Getting started

Typed Variables

Schema-driven types generated from your dashboard, with no manual Zod and no runtimeEnv mapping.

The schema lives in Envless

Every variable in the dashboard has a type, a required/optional flag, a default, and a visibility. envless types reads the name, the type and the visibility, and generates a TypeScript declaration file from them. You don't repeat it in code.

The envless command ships in @goenvless/cli. Install it with npm i -g @goenvless/cli@latest.

In Python, python -m envless_env types generates the same thing as a typed module for envless-env. In Ruby, bundle exec envless-env types writes it as RBS signatures for the envless-env gem. In PHP, vendor/bin/envless-env types writes it as a typed class for envless/env.

Dashboard fieldEffect in code
NameBecomes a property on env, and a key in process.env unless the loader refuses the name (NODE_OPTIONS, PATH, LD_PRELOAD and the rest of that family). Names the env proxy reserves as plumbing, toJSON, then and everything Object.prototype owns, still reach process.env and the record load() returns, but are not readable as env.<NAME>
Type (string, number, boolean)Becomes the property type and triggers coercion on read
RequiredNo effect in code. Codegen types every variable as a required property, and the proxy throws for any name it doesn't hold, required or not
DefaultNo effect in code. The loader never falls back to it, so a variable with no value arrives as an empty string
Visibility (server / client)Only the /client proxy filters on it. /server and the root import expose every loaded variable

Generate types

bash
envless types

Writes envless-env.d.ts to your current directory. It reads only variable names, types, and visibility. No decryption key is required, so it works even when the workspace is locked. It does need a signed-in session or ENVLESS_TOKEN, plus a project and an environment. Every target resolves in the same order: the flag wins, then the environment variable, then the .envless file. That is --project, ENVLESS_PROJECT, .envless for the project, --env, ENVLESS_ENV, .envless for the environments, and --product, ENVLESS_PRODUCT, .envless for the product, which is optional and only matters when the same project slug exists in more than one product. A user token also needs a workspace, from --workspace or the .envless file, since there is no ENVLESS_WORKSPACE; a machine key (ev_sk_…) carries its own.

The generated file has up to three declaration blocks:

ts
// Generated by envless, do not edit by hand.
// Run `envless types` to regenerate.

declare global {
    namespace NodeJS {
        interface ProcessEnv {
            DATABASE_URL: string
            FEATURE_X_ENABLED: string
            PORT: string
            PUBLIC_API_URL: string
            STRIPE_SECRET: string
        }
    }
}

declare module '@goenvless/env/server' {
    interface EnvlessServerEnv {
        DATABASE_URL: string
        FEATURE_X_ENABLED: boolean
        PORT: number
        PUBLIC_API_URL: string
        STRIPE_SECRET: string
    }
}

declare module '@goenvless/env/client' {
    interface EnvlessClientEnv {
        PUBLIC_API_URL: string
    }
}

export {}
BlockCoversTypes
NodeJS.ProcessEnvevery variable, always emittedalways string, because process.env is always strings
EnvlessServerEnvevery variable, omitted when the environment has nonetyped by the variable's type (the /server proxy coerces)
EnvlessClientEnvonly client-visibility variables, omitted when there are nonesame coerced typing

The last two blocks augment the empty EnvlessServerEnv / EnvlessClientEnv interfaces @goenvless/env ships. That's how import { env } from '@goenvless/env/server' becomes fully typed. The first block types raw process.env, so even code that never imports @goenvless/env (or that runs after envless run / sync) gets autocomplete.

There is no fourth block. Codegen never augments the root @goenvless/env import, whose env stays a bare [key: string]: string | number | boolean index signature. Read your variables through /server or /client if you want the schema enforced.

Commit this file. It contains no secrets, only the shape. Committing it means teammates get autocomplete without running anything.

Keep types in sync

Run envless types whenever you add or rename a variable in the dashboard. Or wire it up so it runs automatically:

{
    "devDependencies": {
        "@goenvless/cli": "latest"
    },
    "scripts": {
        "postinstall": "envless types",
        "dev": "envless types && next dev"
    }
}

Coercion

Values are stored as strings in the encrypted bundle. The env proxy coerces them on read based on the schema type:

ts
env.PORT              // number,  "3000"  → 3000
env.FEATURE_X_ENABLED // boolean, "true"  → true   ("true" or "1" → true)
env.DATABASE_URL      // string,  unchanged

Coercion happens on every read, through all three proxies. There's no Number(env.PORT) ceremony. (Raw process.env.PORT is still the underlying string; coercion is a property of the @goenvless/env proxy, not process.env.)

Only /server and /client also declare the coerced type. The root import coerces at runtime but declares every value as string | number | boolean, so env.PORT there is a number you have to narrow before you can use it as one, and a misspelled name still type checks because the index signature accepts anything. That gap is another reason to read through /server.

Missing variables throw loudly

ts
env.NOT_A_REAL_KEY    // throws: Missing env var: NOT_A_REAL_KEY

This is intentional: silent undefined is the single worst thing about process.env. The throw fires on any name the store doesn't hold, and the dashboard default is not consulted.

For a variable that may legitimately be absent you have two options. Create it in the dashboard with an empty value, so it loads as '' and you check for ''. Or read it through optional, exported by /server and /client alongside env, which returns undefined for a name the store does not hold instead of throwing. optional is typed Partial<EnvlessServerEnv>, so TypeScript still makes you handle the empty case.

Reading env before the async load has finished throws a different, guiding message: Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first. See Import in Your App for the loading model.

Test overrides

For tests, layer temporary values over the store without mutating global state:

ts
import { env, load, __override } from '@goenvless/env/server';

test('feature X enabled', async () => {
    await load();

    using _ = __override({ FEATURE_X_ENABLED: 'true' });

    expect(env.FEATURE_X_ENABLED).toBe(true);   // the schema says boolean, so it coerces
});

__override(values) takes raw string values and returns a Symbol.dispose handle. The using declaration scopes the override to the block and pops exactly that layer when it exits, so no cleanup is needed. Without using, call the returned handle's disposer yourself.

An override carries values, never metadata. Coercion reads the type a load stored, so an overridden name that a load already brought in keeps its schema type, while a name the store has never seen falls back to string and server and reads back as the raw string you passed. That is why the sample awaits load() first: an override on its own is enough to stop the not-loaded throw, but not enough to make FEATURE_X_ENABLED a boolean.

Keeping the declaration current

A generated file goes stale the moment someone adds, renames or removes a variable in the dashboard. The stale copy keeps compiling, so the drift is invisible: code can reference a variable that no longer exists and nothing complains.

envless run, envless build and envless sync fix that for you. After fetching, they compare your envless-env.d.ts against what the workspace actually holds and rewrite it when they differ, reporting what moved.

bash
envless run --env local -- bun run dev
# › types envless-env.d.ts updated (+1 -1)

It costs nothing extra: those commands already fetched the names, types and visibility, so no additional request is made and no decryption key is involved.

The sync only runs when envless-env.d.ts already exists. A project that doesn't use the typed reader is never touched, so this is opt in by the presence of the file.

It is also skipped in two cases where writing would be wrong:

CaseWhy
A machine key (ev_sk_…)The image is already built, and its filesystem may be read only
The file does not existThe project does not use typed variables

Turning it off

Three ways, in the order they are checked:

bash
ENVLESS_NO_TYPES_SYNC=1 envless run --env local -- bun run dev
envless.json
{
    "workspace": "acme",
    "product": "billing",
    "project": "api",
    "environments": ["local"],
    "types": { "sync": false }
}

There is no precedence here: the two switches are OR'd, and either one on its own turns the sync off. ENVLESS_NO_TYPES_SYNC=0 does not re-enable a sync that envless.json disabled, and neither switch can turn one on. Deleting envless-env.d.ts turns it off permanently, since the sync never creates one.

Catching drift in CI

The sync keeps the file fresh while you work, but it cannot help a checkout nobody has run. Add a gate to your check script so a stale declaration cannot be merged:

package.json
{
    "scripts": {
        "check": "envless types && git diff --exit-code envless-env.d.ts && tsc --noEmit && eslint ."
    }
}

Regenerate, then fail if the committed file changed. Keep envless-env.d.ts committed either way: it is what lets a teammate typecheck without any credentials.

Why this beats t3-env / hand-rolled Zod

Hand-rolled validationenvless
Schema locationLives in code, drifts from .envLives in the dashboard, single source
Add a variableEdit env.ts, edit .env, edit .env.example, commitAdd it in the dashboard, run envless types
Required/defaultDeclared and enforced in ZodRecorded in the dashboard, not applied by the runtime
Client/server splitManual client: / server: blocksVisibility flag in dashboard
Runtime valuesManually mapped via runtimeEnv: { ... }Automatic