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 field | Effect in code |
|---|---|
| Name | Becomes 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 |
| Required | No 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 |
| Default | No 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
envless typesWrites 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:
// 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 {}| Block | Covers | Types |
|---|---|---|
NodeJS.ProcessEnv | every variable, always emitted | always string, because process.env is always strings |
EnvlessServerEnv | every variable, omitted when the environment has none | typed by the variable's type (the /server proxy coerces) |
EnvlessClientEnv | only client-visibility variables, omitted when there are none | same 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"
}
}- run: npx @goenvless/cli types
env:
ENVLESS_TOKEN: ${{ secrets.ENVLESS_TOKEN }}
- run: npx tsc --noEmitCoercion
Values are stored as strings in the encrypted bundle. The env proxy coerces them on read based on the schema type:
env.PORT // number, "3000" → 3000
env.FEATURE_X_ENABLED // boolean, "true" → true ("true" or "1" → true)
env.DATABASE_URL // string, unchangedCoercion 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
env.NOT_A_REAL_KEY // throws: Missing env var: NOT_A_REAL_KEYThis 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:
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.
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:
| Case | Why |
|---|---|
A machine key (ev_sk_…) | The image is already built, and its filesystem may be read only |
| The file does not exist | The project does not use typed variables |
Turning it off
Three ways, in the order they are checked:
ENVLESS_NO_TYPES_SYNC=1 envless run --env local -- bun run dev{
"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:
{
"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 validation | envless | |
|---|---|---|
| Schema location | Lives in code, drifts from .env | Lives in the dashboard, single source |
| Add a variable | Edit env.ts, edit .env, edit .env.example, commit | Add it in the dashboard, run envless types |
| Required/default | Declared and enforced in Zod | Recorded in the dashboard, not applied by the runtime |
| Client/server split | Manual client: / server: blocks | Visibility flag in dashboard |
| Runtime values | Manually mapped via runtimeEnv: { ... } | Automatic |