From 1994008a5020df0641822424438c4f0aa3d689ce Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 8 Jun 2026 19:00:01 -0500 Subject: [PATCH] feat(zod): add `json` primitive to @vlandoss/env/zod `e.json(schema)` decodes a JSON-string env var into a validated object and also accepts the already-decoded object coming from a config file / defaults (codec `.or(schema)`, the same dual-source pattern as `e.bool`). This lets a single leaf back a structured value whether it arrives as a `process.env` string or as a real object from a `.ts`/`.json` config. - package/src/zod.ts: new `json` factory - tests: env-var string decode, decoded-config passthrough, invalid-JSON dotpath - docs: Exports row + `json` section (rate-limit config example) - landing: bump displayed version to v0.4.0 (the release this changeset cuts) Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/json-primitive.md | 8 +++++ docsite/content/docs/api-reference/zod.mdx | 32 ++++++++++++++++++++ docsite/src/components/landing/data.ts | 2 +- package/src/__tests__/zod.test.ts | 34 ++++++++++++++++++++++ package/src/zod.ts | 19 ++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 .changeset/json-primitive.md diff --git a/.changeset/json-primitive.md b/.changeset/json-primitive.md new file mode 100644 index 0000000..f5f7979 --- /dev/null +++ b/.changeset/json-primitive.md @@ -0,0 +1,8 @@ +--- +"@vlandoss/env": minor +--- + +Add `json` primitive to `@vlandoss/env/zod`: `e.json(schema)` decodes a +JSON-string env var into a validated object and also accepts the decoded +object from config files / defaults (codec `.or(schema)`, same dual-source +pattern as `e.bool`). diff --git a/docsite/content/docs/api-reference/zod.mdx b/docsite/content/docs/api-reference/zod.mdx index a633a66..3bb5726 100644 --- a/docsite/content/docs/api-reference/zod.mdx +++ b/docsite/content/docs/api-reference/zod.mdx @@ -16,12 +16,44 @@ This entrypoint is **optional**: it's there because we wanted it ourselves, not | `host` | Schema | A non-empty hostname string. | | `bool` | Schema | Loose boolean parser — accepts `"true"`/`"false"`/`"1"`/`"0"` and friends. | | `secret` | Schema | Non-empty string that gets redacted from console output and error messages. | +| `json` | Schema factory | Parse a JSON-string env var into a validated object; also accepts the decoded object from config files. | Full signatures and behavioral notes are coming soon. Until then, treat these as drop-in Zod schemas — they expose the standard Zod API. +## `json` + +`json` is a factory: pass it the schema for the decoded shape and it returns a leaf that handles both sources a leaf can come from. A `process.env` var arrives as a JSON **string** and gets parsed and validated; a value from a config file or `defaults` arrives **already decoded** and is validated as-is. Either way the leaf infers as the decoded object — never `string`. + +```ts title="src/env/schema.ts" +import { schema } from "@vlandoss/env"; +import * as e from "@vlandoss/env/zod"; +import * as z from "zod"; + +const RateLimit = z.object({ + windowMs: z.number().int().positive(), + max: z.number().int().positive(), +}); + +export const Env = schema({ + rateLimit: { CONFIG: e.json(RateLimit) }, +}); +``` + +```ts title="both sources validate against the same leaf" +// from a process.env var — a JSON string, parsed then validated: +// RATE_LIMIT_CONFIG='{"windowMs":60000,"max":100}' + +// from a config file — the decoded object, validated as-is: +export default { + rateLimit: { CONFIG: { windowMs: 60_000, max: 100 } }, +} satisfies EnvConfig; +``` + +Invalid JSON fails validation at boot with the offending dotpath (e.g. `Invalid value at "rateLimit.CONFIG"`), the same as any other leaf. This is the same dual-source pattern as [`bool`](#exports), which accepts both `"true"` strings and real booleans. + ## See also - [Quickstart](/docs/getting-started/quickstart) — the snippets use plain Zod, but each primitive can replace its long form. diff --git a/docsite/src/components/landing/data.ts b/docsite/src/components/landing/data.ts index eda2d32..d9a3d84 100644 --- a/docsite/src/components/landing/data.ts +++ b/docsite/src/components/landing/data.ts @@ -116,7 +116,7 @@ export const WHY_POINTS = [ ] as const; export const LANDING_META = { - version: "v0.3.0", + version: "v0.4.0", vlandUrl: "https://variable.land", githubUrl: "https://github.com/variableland/env", npmUrl: "https://www.npmjs.com/package/@vlandoss/env", diff --git a/package/src/__tests__/zod.test.ts b/package/src/__tests__/zod.test.ts index b1fed11..2ff2443 100644 --- a/package/src/__tests__/zod.test.ts +++ b/package/src/__tests__/zod.test.ts @@ -89,3 +89,37 @@ describe("@vlandoss/env/zod — primitives compose with schema()", () => { ).toThrow(/auth\.SECRET/); }); }); + +describe("json — decodes string env vars and accepts decoded config values", () => { + const Env = schema({ + rateLimit: { + CONFIG: e.json(z.object({ windowMs: z.number().int().positive(), max: z.number().int().positive() })), + }, + }); + + it("decodes a JSON-string env var into an object", () => { + const env = defineEnv({ + schema: Env, + runtimeEnv: { RATE_LIMIT_CONFIG: '{"windowMs":60000,"max":100}' }, + }); + expect(env.rateLimit.CONFIG).toEqual({ windowMs: 60000, max: 100 }); + }); + + it("accepts an already-decoded object from config", () => { + const env = defineEnv({ + schema: Env, + config: { rateLimit: { CONFIG: { windowMs: 60000, max: 100 } } }, + runtimeEnv: {}, + }); + expect(env.rateLimit.CONFIG).toEqual({ windowMs: 60000, max: 100 }); + }); + + it("throws with the dotpath on invalid JSON", () => { + expect(() => + defineEnv({ + schema: Env, + runtimeEnv: { RATE_LIMIT_CONFIG: "not json" }, + }), + ).toThrow(/rateLimit\.CONFIG/); + }); +}); diff --git a/package/src/zod.ts b/package/src/zod.ts index f7d410c..e79be00 100644 --- a/package/src/zod.ts +++ b/package/src/zod.ts @@ -41,3 +41,22 @@ export const logLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trac /** Long-enough secret for signing / session use. Minimum 16 chars. */ export const secret = z.string().min(16); + +/** + * JSON-string env var decoded into `schema`, while also accepting an + * already-decoded value from a config file / defaults (codec `.or(schema)`). + */ +export const json = (schema: T) => + z + .codec(z.string(), schema, { + decode: (str, ctx) => { + try { + return JSON.parse(str); + } catch (err) { + ctx.issues.push({ code: "invalid_format", format: "json", input: str, message: (err as Error).message }); + return z.NEVER; + } + }, + encode: (value) => JSON.stringify(value), + }) + .or(schema);