Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/json-primitive.md
Original file line number Diff line number Diff line change
@@ -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`).
32 changes: 32 additions & 0 deletions docsite/content/docs/api-reference/zod.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

<Callout type="info">
Full signatures and behavioral notes are coming soon. Until then, treat these
as drop-in Zod schemas — they expose the standard Zod API.
</Callout>

## `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.
Expand Down
2 changes: 1 addition & 1 deletion docsite/src/components/landing/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions package/src/__tests__/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
19 changes: 19 additions & 0 deletions package/src/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends z.core.$ZodType>(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);
Loading