npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-env

Usage

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 access

How 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)flat includes every declared var.
  • structure.client(flat)flat includes only client-marked vars. Referencing a server-only var here is a compile error. This is what client: true actually 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)flat is typed against every declared var. Computes the server-side structured shape.
  • structure.client(flat)flat is typed against only { client: true }-marked vars. Referencing a server-only var here is a compile error. This is the single thing client: true enforces. At runtime, loadClient projects 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 required isServer predicate gates execution; if it returns false, the call returns a failure (does not throw). files accepts file paths, inline objects (typed against vars — typos are compile errors), runtimeEnv(...) wrappers, and mergeEnv(...) results.
  • loadClient({ files, schemas, mode, skip? }) — runs only structure.client, validates the client schema, returns { mode, client }. No gate — safe everywhere. files accepts the same source types as loadServer except file path strings (clients have no filesystem; passing a path is a compile error).
  • Two entry points (jimkit-env resolves to a slim build under the browser condition). The slim build types loadServer as never, so client-resolved imports get a compile error if they try to call it. The runtime isServer check 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 defineVars are visible in client bundles when vars is shared. They aren't secrets in the strict sense (knowing your app reads DATABASE_URL doesn't reveal the value), but it's worth knowing.
  • structure.client callback body is in the client bundle if the file containing it is imported by client code. structure.server is 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.X references on the client, so server values evaluate to undefined.

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 against process.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 dynamic Record (defaults to process.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 via env.vars.NAME.description.

defineEnv({ vars, structure })

  • vars — output of defineVars.
  • structure{ server: (flat) => {...}, client: (flat) => {...} }. structure.server's flat includes every declared var; structure.client's flat includes 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 of structure's output.
  • mode — current mode string (or undefined if skip.when is true).
  • isServer — required predicate, called at the start of every loadServer call. Returns a failure if it returns false.
  • files? — ordered EnvSource[]. First that resolves wins. Defaults to [runtimeEnv()]. Accepts file paths, inline objects (typed against vars), runtimeEnv(...), and mergeEnv(...) results.
  • skip?{ when, mode, overrideServer?, overrideClient? }.

Returns EnvResult<{ mode, server, client }>, discriminated by mode.

EnvShape.loadClient({ schemas, mode, files?, skip? })

  • schemas{ [mode]: ZodType }. Validates structure's client subtree.
  • mode — same as above.
  • files? — ordered ClientEnvSource[]. Like EnvSource but 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).