jimkit-env
v0.8.0
Published
Type-safe environment variable validation using Zod.
Downloads
1,811
Readme
jimkit-env
Type-safe environment variable validation using Zod.
npm install jimkit-envUsage
import { defineEnv, defineVars } from "jimkit-env";
import { z } from "zod";
const env = defineEnv({
vars: defineVars({
DATABASE_URL: {}, // server-only (default)
AUTH_SECRET: {},
NEXT_PUBLIC_API_URL: { client: true }, // opt-in client-safe
}),
// structure has separate server/client callbacks; client only sees client-marked vars
structure: {
server: (flat) => ({
db: { url: flat.DATABASE_URL ?? "file:local.db" },
auth: { secret: flat.AUTH_SECRET ?? "" },
}),
client: (flat) => ({
api: { url: flat.NEXT_PUBLIC_API_URL ?? "" },
// flat.DATABASE_URL → compile error: not in client flat
}),
},
});
// In server-only code
const serverResult = env.loadServer({
isServer: () => typeof window === "undefined",
files: [".env.local", runtimeEnv()],
schemas: {
development: {
server: z.object({ db: z.object({ url: z.string() }), auth: z.object({ secret: z.string() }) }),
client: z.object({ api: z.object({ url: z.string() }) }),
},
production: {
server: z.object({ db: z.object({ url: z.string().url() }), auth: z.object({ secret: z.string().min(32) }) }),
client: z.object({ api: z.object({ url: z.string().url() }) }),
},
},
mode: process.env.NODE_ENV === "production" ? "production" : "development",
});
if (!serverResult.success) {
console.error(serverResult.error.message);
process.exit(1);
}
// serverResult.data: { mode, server, client } — full env
// In client/shared code
const clientResult = env.loadClient({
files: [
{ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL }, // bundler inlines literal access
],
schemas: {
development: z.object({ api: z.object({ url: z.string() }) }),
production: z.object({ api: z.object({ url: z.string().url() }) }),
},
mode: process.env.NODE_ENV === "production" ? "production" : "development",
});
if (!clientResult.success) throw clientResult.error;
// clientResult.data: { mode, client } — server is a compile error to accessHow it works
defineVars declares your env vars and which are client-safe ({ client: true } opts a var into the client surface; default is server-only). structure has two callbacks:
structure.server(flat)—flatincludes every declared var.structure.client(flat)—flatincludes only client-marked vars. Referencing a server-only var here is a compile error. This is whatclient: trueactually enforces.
Each side is then validated by per-mode schemas. loadServer returns the full { mode, server, client } result. loadClient returns just { mode, client } — accessing .server is a compile error.
loadServer requires an isServer: () => boolean predicate that runs at the top of every call; if it returns false, loadServer throws. There's no built-in default — every project declares its own check (typically () => typeof window === "undefined", or () => Boolean(process.env.IS_SERVER) for explicit flags).
Anatomy — what each piece does
Quick map of which API surface enforces what:
defineVars({ NAME: { client?: boolean, description?: string } })— declares the env vars and per-var metadata.{ client: true }opts a var into the client surface; default is server-only.structure.server(flat)—flatis typed against every declared var. Computes the server-side structured shape.structure.client(flat)—flatis typed against only{ client: true }-marked vars. Referencing a server-only var here is a compile error. This is the single thingclient: trueenforces. At runtime,loadClientprojects the source down so server vars never appear in this callback's input.loadServer({ isServer, files, schemas, mode, skip? })— runs both structures, validates both schemas, returns{ mode, server, client }. The requiredisServerpredicate gates execution; if it returns false, the call returns a failure (does not throw).filesaccepts file paths, inline objects (typed againstvars— typos are compile errors),runtimeEnv(...)wrappers, andmergeEnv(...)results.loadClient({ files, schemas, mode, skip? })— runs onlystructure.client, validates the client schema, returns{ mode, client }. No gate — safe everywhere.filesaccepts the same source types asloadServerexcept file path strings (clients have no filesystem; passing a path is a compile error).- Two entry points (
jimkit-envresolves to a slim build under thebrowsercondition). The slim build typesloadServerasnever, so client-resolved imports get a compile error if they try to call it. The runtimeisServercheck is the backstop for setups that don't honor the condition.
Server / client entry points
The package ships two entry points, resolved automatically by bundlers via the browser export condition:
| Import | Available in client builds | loadServer available |
|------------------------------|----------------------------|------------------------|
| import "jimkit-env" | Yes (resolves to slim) | No (never) |
| import "jimkit-env" | Server (resolves to full) | Yes |
| import "jimkit-env/server" | n/a (server-only) | Yes |
| import "jimkit-env/client" | Yes | No (never) |
In bundlers that honor the browser condition (Vite, esbuild, Next.js client, modern Webpack), calling .loadServer(...) from client code is a compile error. The explicit /server and /client subpaths give you the same protection in setups that don't honor the condition.
Working with bundlers (Next.js, Vite, etc.)
Bundlers like Next.js statically inline values for process.env.X accesses, but only when X is a literal at the source level. Bare process.env in a client-bundled context becomes {} (or a heavily-redacted object), and only literal process.env.NEXT_PUBLIC_*-style accesses survive.
For client-context use, pass an explicit literal-access map to loadClient.files:
env.loadClient({
files: [
{
// Each access is a literal `process.env.X` — Next.js inlines what it can
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_FLAG: process.env.NEXT_PUBLIC_FLAG,
},
],
schemas, mode,
});Bare process.env is rejected by the type checker for loadServer.files too (use runtimeEnv(process.env) if you genuinely need a passthrough Record). For server contexts, the implicit default [runtimeEnv()] already reads process.env — pass files: [runtimeEnv()] if you want it spelled out.
What leaks to the client bundle
- Var names declared in
defineVarsare visible in client bundles whenvarsis shared. They aren't secrets in the strict sense (knowing your app readsDATABASE_URLdoesn't reveal the value), but it's worth knowing. structure.clientcallback body is in the client bundle if the file containing it is imported by client code.structure.serveris a separate property and most bundlers tree-shake it from client builds, but server-side computations on the client subtree's path are visible.- Values are not leaked, assuming you use the literal-source pattern above. Bundlers like Next.js never substitute non-public
process.env.Xreferences on the client, so server values evaluate toundefined.
If you need server var names absent from client bundles too, put server vars in a server-only file (e.g. a separate defineEnv call in a file the client never imports).
Skip path (local build verification)
env.loadServer({
isServer: () => typeof window === "undefined",
schemas,
mode,
skip: {
when: Boolean(process.env.SKIP_ENV_VALIDATION),
mode: "production",
overrideServer: (draft) => ({ ...draft, db: { url: "libsql://example.com" } }),
overrideClient: (draft) => ({ ...draft, api: { url: "https://example.com" } }),
},
});When skip.when is true, structure runs against stub values, optional overrideServer/overrideClient callbacks tweak the drafts, and the result is validated against the chosen mode's schemas. Source resolution is bypassed.
loadClient.skip accepts only overrideClient.
Custom env sources
files is configured per-load. Server and client take different source types:
loadServer.files accepts:
- a file path (
string) — relative paths resolve againstprocess.cwd(), missing files are skipped; - an inline object with keys constrained to your declared
vars— typos are compile errors; - a
mergeEnv(...)result — failure propagates; - a
runtimeEnv(...)result — wraps a dynamicRecord(defaults toprocess.env) without the typo check.
env.loadServer({
isServer,
files: [`.env.${process.env.NODE_ENV}`, ".env", runtimeEnv()],
schemas, mode,
});loadClient.files accepts the same source types except file path strings — the client has no filesystem to read, and silently no-oping a path would be a footgun. Passing one is a compile error.
env.loadClient({
files: [
{ NEXT_PUBLIC_API: process.env.NEXT_PUBLIC_API }, // bundler inlines literal access
],
schemas, mode,
});The first source that resolves wins (no merging across files). Defaults to [runtimeEnv()] if files is omitted entirely.
For Next.js client builds (where bundlers only inline literal process.env.X accesses), write a typed inline map — runtimeEnv(process.env) won't help on the client because process.env is replaced with {}.
For last-wins merging on the server:
import { mergeEnv } from "jimkit-env";
env.loadServer({
isServer,
files: [mergeEnv([process.env, ".env"])], // .env overrides process.env
schemas, mode,
});Errors
const result = env.loadServer({...});
if (!result.success) {
console.error(result.error.message);
// Environment validation failed:
// server.db.url: Required but missing
// server.auth.secret: Expected string, received undefined
process.exit(1);
}Narrow with result.error instanceof EnvValidationError for .issues (raw zod issues).
Error messages contain the path, the expected shape, and (for type mismatches) the runtime type of the received value — never the literal value itself. Safe to log to error reporters, CI output, or anywhere else that aggregates logs.
A value can still appear in a message if you embed it in a .refine(..., "msg") string or other zod-side text — those are passed through verbatim.
result.description categorizes failures: "Schema validation failed", "Structure callback failed", "Skip override callback failed", "Skip override validation failed", "Mode is undefined", "Unknown mode", "No env sources resolved", "Server context check failed".
API
defineVars(vars)
Typed pass-through that declares env vars + per-var metadata.
defineVars({
DATABASE_URL: { description: "Postgres connection string" },
NEXT_PUBLIC_API_URL: { client: true, description: "Public API origin" },
});Metadata fields:
client?: boolean— opts the var into the client surface (default: server-only).description?: string— free-form annotation, accessible viaenv.vars.NAME.description.
defineEnv({ vars, structure })
vars— output ofdefineVars.structure—{ server: (flat) => {...}, client: (flat) => {...} }.structure.server'sflatincludes every declared var;structure.client'sflatincludes only client-marked vars.
Returns an EnvShape with loadServer and loadClient methods.
EnvShape.loadServer({ schemas, mode, isServer, files?, skip? })
schemas—{ [mode]: { server: ZodType, client: ZodType } }. Validates each side ofstructure's output.mode— current mode string (orundefinedifskip.whenis true).isServer— required predicate, called at the start of everyloadServercall. Returns a failure if it returns false.files?— orderedEnvSource[]. First that resolves wins. Defaults to[runtimeEnv()]. Accepts file paths, inline objects (typed againstvars),runtimeEnv(...), andmergeEnv(...)results.skip?—{ when, mode, overrideServer?, overrideClient? }.
Returns EnvResult<{ mode, server, client }>, discriminated by mode.
EnvShape.loadClient({ schemas, mode, files?, skip? })
schemas—{ [mode]: ZodType }. Validatesstructure'sclientsubtree.mode— same as above.files?— orderedClientEnvSource[]. LikeEnvSourcebut excludes file path strings — passing one is a compile error.skip?—{ when, mode, overrideClient? }.
Returns EnvResult<{ mode, client }>. Has no server field.
mergeEnv(sources)
Last-wins merge over EnvSource[]. Returns an EnvResult that plugs into defineEnv.files.
Types
type EnvSource = string | Record<string, string | undefined> | MergedEnvResult;
type EnvResult<T> = { success: true; data: T } | { success: false; error: Error; description: string };EnvValidationError extends Error with .issues (raw ZodIssue[]) and .cause (the original ZodError).
