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);