From bc210d46b1da62a575717d95e5735191bd422c1f Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Sun, 13 Jul 2025 21:46:44 +0200 Subject: [PATCH 1/9] feat: added zod4 support, wip --- package.json | 6 +- pnpm-lock.yaml | 10 +- src/bridge.test.ts | 68 +++++ src/bridge.ts | 47 +++ src/extra-schemas.test.ts | 2 +- src/extra-schemas.ts | 2 +- src/index.ts | 2 +- src/parse-env.test.ts | 4 +- src/parse-env.ts | 20 +- src/preprocessors.ts | 107 ++----- src/reporter.ts | 2 +- src/shared/processing.ts | 98 ++++++ src/shared/utils.ts | 16 + src/v4/extra-schemas.test.ts | 18 ++ src/v4/extra-schemas.ts | 8 + src/v4/parse-env.test.ts | 570 +++++++++++++++++++++++++++++++++++ src/v4/parse-env.ts | 162 ++++++++++ src/v4/preprocessors.ts | 139 +++++++++ 18 files changed, 1165 insertions(+), 116 deletions(-) create mode 100644 src/bridge.test.ts create mode 100644 src/bridge.ts create mode 100644 src/shared/processing.ts create mode 100644 src/shared/utils.ts create mode 100644 src/v4/extra-schemas.test.ts create mode 100644 src/v4/extra-schemas.ts create mode 100644 src/v4/parse-env.test.ts create mode 100644 src/v4/parse-env.ts create mode 100644 src/v4/preprocessors.ts diff --git a/package.json b/package.json index 4cf3ca6..3f1fe31 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "prepublishOnly": "run-s -l test build" }, "peerDependencies": { - "zod": "^3.24.2" + "zod": "^3.25.0 || ^4.0.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", @@ -79,7 +79,7 @@ "ts-jest": "^29.2.6", "ts-node": "^10.9.2", "typescript": "^5.8.2", - "zod": "~3.24.2" + "zod": "^4" }, "jest": { "preset": "ts-jest/presets/default-esm", @@ -100,5 +100,5 @@ ] } }, - "packageManager": "pnpm@10.6.5+sha512.cdf928fca20832cd59ec53826492b7dc25dc524d4370b6b4adbf65803d32efaa6c1c88147c0ae4e8d579a6c9eec715757b50d4fa35eea179d868eada4ed043af" + "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27253af..8f6f3dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: ^5.8.2 version: 5.8.2 zod: - specifier: ~3.24.2 - version: 3.24.2 + specifier: ^4 + version: 4.0.5 packages: @@ -2632,8 +2632,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.24.2: - resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@4.0.5: + resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} snapshots: @@ -5850,4 +5850,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.24.2: {} + zod@4.0.5: {} diff --git a/src/bridge.test.ts b/src/bridge.test.ts new file mode 100644 index 0000000..9fe3f71 --- /dev/null +++ b/src/bridge.test.ts @@ -0,0 +1,68 @@ +import * as z3 from "zod/v3"; +import * as z4 from "zod/v4"; +import { parseEnv } from "./bridge.js"; + +describe("zod3 and zod4 support", () => { + it("should support zod3", () => { + const x = parseEnv( + { + HOST: "localhost", + }, + { + HOST: z3.string(), + }, + ); + expect(x).toStrictEqual({ + HOST: "localhost", + }); + }); + it("should support zod3 with extended schemas", () => { + const x = parseEnv( + { + HOST: "localhost", + }, + { + HOST: z3.string(), + PORT: { + schema: z3.number(), + defaults: { _: 4040 }, + }, + }, + ); + expect(x).toStrictEqual({ + HOST: "localhost", + PORT: 4040, + }); + }); + it("should support zod4", () => { + const x = parseEnv( + { + HOST: "localhost", + }, + { + HOST: z4.string(), + }, + ); + expect(x).toStrictEqual({ + HOST: "localhost", + }); + }); + it("should support zod4 with extended schemas", () => { + const x = parseEnv( + { + HOST: "localhost", + }, + { + HOST: z4.string(), + PORT: { + schema: z4.number(), + defaults: { _: 4040 }, + }, + }, + ); + expect(x).toStrictEqual({ + HOST: "localhost", + PORT: 4040, + }); + }); +}); diff --git a/src/bridge.ts b/src/bridge.ts new file mode 100644 index 0000000..5b994a0 --- /dev/null +++ b/src/bridge.ts @@ -0,0 +1,47 @@ +import { + parseEnvImpl as parseEnvImplV4, + ParsedSchema as ParsedSchemaV4, + RestrictSchemas as RestrictSchemasV4, + Schemas as SchemasV4, +} from "./v4/parse-env.js"; +import { + parseEnv as parseEnvImplV3, + ParsedSchema as ParsedSchemaV3, + RestrictSchemas as RestrictSchemasV3, + Schemas as SchemasV3, +} from "./index.js"; +import type { DeepReadonlyObject } from "./util/type-helpers.js"; + +const isZodV4Schema = ( + schema: Record, +): schema is SchemasV4 & RestrictSchemasV4 => { + const firstKey = Object.keys(schema)[0]!; + const thisSchema = schema[firstKey].schema ?? schema[firstKey]; + return "_zod" in thisSchema && typeof thisSchema._zod === "object"; +}; + +export function parseEnv< + V3SchemaT extends SchemasV3 & RestrictSchemasV3, +>( + env: Record, + schemas: V3SchemaT, +): DeepReadonlyObject>; +export function parseEnv< + V4SchemaT extends SchemasV4 & RestrictSchemasV4, +>( + env: Record, + schemas: V4SchemaT, +): DeepReadonlyObject>; +export function parseEnv( + env: Record, + schemas: Record, +) { + if (isZodV4Schema(schemas)) { + return parseEnvImplV4(env, schemas); + } + // we assume zod3 otherwise + return parseEnvImplV3( + env, + schemas as SchemasV3 & RestrictSchemasV3, + ); +} diff --git a/src/extra-schemas.test.ts b/src/extra-schemas.test.ts index fae90a4..21de797 100644 --- a/src/extra-schemas.test.ts +++ b/src/extra-schemas.test.ts @@ -1,7 +1,7 @@ import { parseEnv } from "./index.js"; import { deprecate } from "./extra-schemas.js"; -describe("extra schemas", () => { +describe("extra schemas v3", () => { describe("deprecate", () => { it("throws when a value is passed", () => { expect(() => diff --git a/src/extra-schemas.ts b/src/extra-schemas.ts index ac90064..cb0dd6d 100644 --- a/src/extra-schemas.ts +++ b/src/extra-schemas.ts @@ -1,4 +1,4 @@ -import * as z from "zod"; +import * as z from "zod/v3"; export const port = () => z.number().int().nonnegative().lte(65535); diff --git a/src/index.ts b/src/index.ts index d9d39e4..b77a48e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { z } from "zod"; +export { z } from "zod/v3"; export * from "./parse-env.js"; export * from "./preprocessors.js"; export * from "./extra-schemas.js"; diff --git a/src/parse-env.test.ts b/src/parse-env.test.ts index e9f0f95..d78e865 100644 --- a/src/parse-env.test.ts +++ b/src/parse-env.test.ts @@ -1,11 +1,11 @@ -import * as z from "zod"; +import * as z from "zod/v3"; import { parseEnv } from "./index.js"; import { port } from "./extra-schemas.js"; // FIXME: many of these don't need to be part of parseCore tests, or at minimum // can be categorized further -describe("parseCore", () => { +describe("parseCore v3", () => { it("handles a basic case", () => { const x = parseEnv( { diff --git a/src/parse-env.ts b/src/parse-env.ts index 1c9b1ca..969df1d 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -1,4 +1,4 @@ -import * as z from "zod"; +import * as z from "zod/v3"; import { getSchemaWithPreprocessor } from "./preprocessors.js"; import { @@ -9,6 +9,7 @@ import { type Reporter, } from "./reporter.js"; +import { resolveDefaultValueForSpec } from "./shared/utils.js"; import type { DeepReadonlyObject } from "./util/type-helpers.js"; export type SimpleSchema = z.ZodType< @@ -79,23 +80,6 @@ export type ParsedSchema = T extends any } : never; -/** - * Since there might be a provided default value of `null` or `undefined`, we - * return a tuple that also indicates whether we found a default. - */ -export function resolveDefaultValueForSpec( - defaults: Record | undefined, - nodeEnv: string | undefined, -): [hasDefault: boolean, defaultValue: TIn | undefined] { - if (defaults) { - if (nodeEnv != null && Object.hasOwn(defaults, nodeEnv)) { - return [true, defaults[nodeEnv]]; - } - if ("_" in defaults) return [true, defaults["_"]]; - } - return [false, undefined]; -} - /** * Mostly an internal convenience function for testing. Returns the input * parameter unchanged, but with the same inference used in `parseEnv` applied. diff --git a/src/preprocessors.ts b/src/preprocessors.ts index 9ccfed1..b628a29 100644 --- a/src/preprocessors.ts +++ b/src/preprocessors.ts @@ -1,6 +1,18 @@ -import * as z from "zod"; +import * as z from "zod/v3"; import { assertNever } from "./util/type-helpers.js"; +import { + bigInt, + boolean, + date, + identity, + json, + nullProcessor, + number, + throwIfUnknown, + throwIfCurrentlyUnsupported, + throwIfWillNeverBeSupported, +} from "./shared/processing.js"; const { ZodFirstPartyTypeKind: TypeName } = z; @@ -18,76 +30,23 @@ export function getPreprocessorByZodType( case TypeName.ZodString: case TypeName.ZodEnum: case TypeName.ZodUndefined: - return (arg) => arg; + return identity; case TypeName.ZodNumber: - return (arg) => { - if (typeof arg === "string" && /^-?\d+(\.\d+)?$/.test(arg)) { - return Number(arg); - } - return arg; - }; + return number; case TypeName.ZodBigInt: - return (arg) => { - if (typeof arg === "string" && /^-?\d+$/.test(arg)) { - return BigInt(arg); - } - return arg; - }; + return bigInt; - // env vars that act as flags might be declared in a number of ways, - // including simply `SOME_VALUE=` (with no RHS). the latter convention - // doesn't seem to be in widespread use with node, though. (that's probably - // because it results in the env var being present as the empty string, - // which is falsy.) - // - // this preprocessor is kind of a hedge -- it accepts a few different - // specific values to signify true or false. i can think of two other - // options: - // - coerce any value that's not `undefined` to `true` (or maybe any value - // that's not `undefined` or `false` or `0`, but again the complexity - // piles up quickly here). - // - coerce *only* 'true' and 'false' to their respective values. this could - // be complemented by a custom schema called 'flag' or something else that - // handles a looser coercion case (for now this is easy for users to do in - // their own code according to their needs). - // - // for now, this hedge seems to work fine, but it might be worth revisiting. case TypeName.ZodBoolean: - return (arg) => { - if (typeof arg === "string") { - // eslint-disable-next-line default-case - switch (arg) { - case "true": - case "yes": - case "1": - return true; - case "false": - case "no": - case "0": - return false; - } - } - return arg; - }; + return boolean; case TypeName.ZodArray: case TypeName.ZodObject: case TypeName.ZodTuple: case TypeName.ZodRecord: case TypeName.ZodIntersection: - return (arg) => { - // neither `undefined` nor the empty string are valid json. - if (!arg) return arg; - // the one circumstance (so far) when i think a preprocessor should be - // able to throw is if we're coercing to json but it's invalid -- this - // way the error message will be more informative (rather than just - // "expected x, got string"). in the future `getPreprocessor` could - // maybe be refined to return a result type instead, but let's not - // overengineer things for now. - return JSON.parse(arg); - }; + return json; case TypeName.ZodEffects: return getPreprocessorByZodType(def.schema); @@ -116,15 +75,7 @@ export function getPreprocessorByZodType( } case TypeName.ZodDate: - return (arg) => { - // calling the 0-arity Date constructor makes a new Date with the - // current time, which definitely isn't what we want here. but calling - // the 1-arity Date constructor, even with `undefined`, should result in - // "invalid date" for values that aren't parseable. we filter out - // `undefined` anyway, though-- it makes typescript happier. - if (arg == null) return arg; - return new Date(arg); - }; + return date; case TypeName.ZodLiteral: switch (typeof def.value) { @@ -145,28 +96,16 @@ export function getPreprocessorByZodType( } case TypeName.ZodNull: - return (arg) => { - // coerce undefined to null. - if (arg == null) return null; - return arg; - }; + return nullProcessor; case TypeName.ZodDiscriminatedUnion: case TypeName.ZodUnion: case TypeName.ZodNativeEnum: - throw new Error( - `Zod type not yet supported: "${typeName}" (PRs welcome)`, - ); + return throwIfCurrentlyUnsupported(typeName); case TypeName.ZodAny: case TypeName.ZodUnknown: - throw new Error( - [ - `Zod type not supported: ${typeName}`, - "You can use `z.string()` or `z.string().optional()` instead of the above type.", - "(Environment variables are already constrained to `string | undefined`.)", - ].join("\n"), - ); + return throwIfUnknown(typeName); // some of these types could maybe be supported (if only via the identity // function), but don't necessarily represent something meaningful as a @@ -184,7 +123,7 @@ export function getPreprocessorByZodType( case TypeName.ZodPipeline: case TypeName.ZodSymbol: case TypeName.ZodReadonly: - throw new Error(`Zod type not supported: ${typeName}`); + return throwIfWillNeverBeSupported(typeName); default: { assertNever(typeName); diff --git a/src/reporter.ts b/src/reporter.ts index 8445d65..417eeef 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -1,4 +1,4 @@ -import { ZodError, ZodErrorMap, ZodIssueCode } from "zod"; +import { ZodError, ZodErrorMap, ZodIssueCode } from "zod/v3"; import type { Schemas } from "./parse-env.js"; // Even though we also have our own formatter, we pass a custom error map to diff --git a/src/shared/processing.ts b/src/shared/processing.ts new file mode 100644 index 0000000..77a3efb --- /dev/null +++ b/src/shared/processing.ts @@ -0,0 +1,98 @@ +type Input = string | undefined | null; + +export const number = (arg: Input) => { + if (typeof arg === "string" && /^-?\d+(\.\d+)?$/.test(arg)) { + return Number(arg); + } + return arg; +}; + +export const bigInt = (arg: Input) => { + if (typeof arg === "string" && /^-?\d+(\.\d+)?$/.test(arg)) { + return BigInt(arg); + } + return arg; +}; + +export const identity = (arg: Input) => arg; + +// env vars that act as flags might be declared in a number of ways, +// including simply `SOME_VALUE=` (with no RHS). the latter convention +// doesn't seem to be in widespread use with node, though. (that's probably +// because it results in the env var being present as the empty string, +// which is falsy.) +// +// this preprocessor is kind of a hedge -- it accepts a few different +// specific values to signify true or false. i can think of two other +// options: +// - coerce any value that's not `undefined` to `true` (or maybe any value +// that's not `undefined` or `false` or `0`, but again the complexity +// piles up quickly here). +// - coerce *only* 'true' and 'false' to their respective values. this could +// be complemented by a custom schema called 'flag' or something else that +// handles a looser coercion case (for now this is easy for users to do in +// their own code according to their needs). +// +// for now, this hedge seems to work fine, but it might be worth revisiting. +export const boolean = (arg: Input) => { + if (typeof arg === "string") { + // eslint-disable-next-line default-case + switch (arg) { + case "true": + case "yes": + case "1": + return true; + case "false": + case "no": + case "0": + return false; + } + } + return arg; +}; + +export const json = (arg: Input) => { + // neither `undefined` nor the empty string are valid json. + if (!arg) return arg; + // the one circumstance (so far) when i think a preprocessor should be + // able to throw is if we're coercing to json but it's invalid -- this + // way the error message will be more informative (rather than just + // "expected x, got string"). in the future `getPreprocessor` could + // maybe be refined to return a result type instead, but let's not + // overengineer things for now. + return JSON.parse(arg); +}; + +export const date = (arg: Input) => { + // calling the 0-arity Date constructor makes a new Date with the + // current time, which definitely isn't what we want here. but calling + // the 1-arity Date constructor, even with `undefined`, should result in + // "invalid date" for values that aren't parseable. we filter out + // `undefined` anyway, though-- it makes typescript happier. + if (arg == null) return arg; + return new Date(arg); +}; + +export const nullProcessor = (arg: Input) => { + // coerce undefined to null. + if (arg == null) return null; + return arg; +}; + +export const throwIfUnknown = (typeName: string) => { + throw new Error( + [ + `Zod type not supported: ${typeName}`, + "You can use `z.string()` or `z.string().optional()` instead of the above type.", + "(Environment variables are already constrained to `string | undefined`.)", + ].join("\n"), + ); +}; + +export const throwIfCurrentlyUnsupported = (typeName: string) => { + throw new Error(`Zod type not yet supported: "${typeName}" (PRs welcome)`); +}; + +export const throwIfWillNeverBeSupported = (typeName: string) => { + throw new Error(`Zod type not supported: ${typeName}`); +}; diff --git a/src/shared/utils.ts b/src/shared/utils.ts new file mode 100644 index 0000000..babe428 --- /dev/null +++ b/src/shared/utils.ts @@ -0,0 +1,16 @@ +/** + * Since there might be a provided default value of `null` or `undefined`, we + * return a tuple that also indicates whether we found a default. + */ +export function resolveDefaultValueForSpec( + defaults: Record | undefined, + nodeEnv: string | undefined, +): [hasDefault: boolean, defaultValue: TIn | undefined] { + if (defaults) { + if (nodeEnv != null && Object.hasOwn(defaults, nodeEnv)) { + return [true, defaults[nodeEnv]]; + } + if ("_" in defaults) return [true, defaults["_"]]; + } + return [false, undefined]; +} diff --git a/src/v4/extra-schemas.test.ts b/src/v4/extra-schemas.test.ts new file mode 100644 index 0000000..2c5b0d4 --- /dev/null +++ b/src/v4/extra-schemas.test.ts @@ -0,0 +1,18 @@ +import { parseEnvImpl as parseEnv } from "./parse-env.js"; +import { deprecate } from "./extra-schemas.js"; + +describe("extra schemas v4", () => { + describe("deprecate", () => { + it("throws when a value is passed", () => { + expect(() => + parseEnv({ DEPRECATED: "something" }, { DEPRECATED: deprecate() }), + ).toThrow(); + + expect(() => + parseEnv({ DEPRECATED: "" }, { DEPRECATED: deprecate() }), + ).toThrow(); + + expect(() => parseEnv({}, { DEPRECATED: deprecate() })).not.toThrow(); + }); + }); +}); diff --git a/src/v4/extra-schemas.ts b/src/v4/extra-schemas.ts new file mode 100644 index 0000000..891daf0 --- /dev/null +++ b/src/v4/extra-schemas.ts @@ -0,0 +1,8 @@ +import * as z from "zod/v4"; + +export const port = () => z.number().int().nonnegative().lte(65535); + +export const deprecate = () => + z + .undefined({ error: "This var is deprecated." }) + .transform(() => undefined as never); diff --git a/src/v4/parse-env.test.ts b/src/v4/parse-env.test.ts new file mode 100644 index 0000000..fc13ea9 --- /dev/null +++ b/src/v4/parse-env.test.ts @@ -0,0 +1,570 @@ +import * as z from "zod/v4"; + +import { parseEnvImpl as parseEnv } from "./parse-env.js"; +import { port } from "./extra-schemas.js"; + +// FIXME: many of these don't need to be part of parseCore tests, or at minimum +// can be categorized further +describe("parseCore v4", () => { + it("handles a basic case", () => { + const x = parseEnv( + { + HOST: "localhost", + PORT: "5050", + }, + { + HOST: z.string(), + PORT: port(), + }, + ); + + expect(x).toStrictEqual({ + HOST: "localhost", + PORT: 5050, + }); + }); + + it("handles a basic case with a zod default", () => { + const x = parseEnv( + { + PORT: "5050", + }, + { + HOST: z.string().default("localhost"), + PORT: port(), + }, + ); + + expect(x).toStrictEqual({ + HOST: "localhost", + PORT: 5050, + }); + }); + + it("returns the correct default based on NODE_ENV", () => { + expect( + parseEnv( + { + NODE_ENV: "production", + HOST: "envhost", + PORT: "5050", + }, + { + HOST: { + schema: z.string(), + defaults: { + production: "prodhost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "envhost", + PORT: 5050, + }); + + expect( + parseEnv( + { + NODE_ENV: "production", + }, + { + HOST: { + schema: z.string(), + defaults: { + production: "prodhost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "prodhost", + PORT: 80, + }); + + expect( + parseEnv( + { + NODE_ENV: "production", + HOST: "envhost", + }, + { + HOST: { + schema: z.string(), + defaults: { + development: "devhost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "envhost", + PORT: 80, + }); + + expect( + parseEnv( + { + NODE_ENV: "production", + }, + { + HOST: { + schema: z.string(), + defaults: { + production: "prodhost", + development: "devhost", + _: "defaulthost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "prodhost", + PORT: 80, + }); + + expect( + parseEnv( + { + NODE_ENV: "production", + HOST: "envhost", + }, + { + HOST: { + schema: z.string(), + defaults: { + production: "prodhost", + development: "devhost", + _: "defaulthost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "envhost", + PORT: 80, + }); + + expect( + parseEnv( + {}, + { + HOST: { + schema: z.string(), + defaults: { + production: "prodhost", + development: "devhost", + _: "defaulthost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "defaulthost", + PORT: 80, + }); + + expect( + parseEnv( + {}, + { + HOST: { + schema: z.string().default("zoddefaulthost"), + defaults: { + production: "prodhost", + development: "devhost", + _: "defaulthost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "defaulthost", + PORT: 80, + }); + + expect( + parseEnv( + {}, + { + HOST: { + schema: z.string().default("zoddefaulthost"), + defaults: { + production: "prodhost", + development: "devhost", + }, + }, + PORT: port().default(80), + }, + ), + ).toStrictEqual({ + HOST: "zoddefaulthost", + PORT: 80, + }); + }); + + it("handles optional values", () => { + const res = parseEnv( + { dogs: "12" }, + { + cats: z.number().nonnegative().optional(), + dogs: z.bigint().optional(), + }, + ); + + expect(res).toStrictEqual({ + cats: undefined, + dogs: 12n, + }); + }); + + it("handles an optional nonempty string", () => { + expect( + parseEnv( + {}, + { + myValue: z.string().nonempty().optional(), + }, + ), + ).toStrictEqual({ + myValue: undefined, + }); + + expect(() => + parseEnv( + { myValue: "" }, + { + myValue: z.string().nonempty().optional(), + }, + ), + ).toThrow(); + }); + + it("fails a string schema when value is missing", () => { + // a missing value should never be coerced to an empty string, for example + expect(() => parseEnv({}, { dogs: z.string() })).toThrow(); + }); + + it("handles a positive int", () => { + expect( + parseEnv({ dogCount: "34" }, { dogCount: z.number() }), + ).toStrictEqual({ + dogCount: 34, + }); + }); + + it("handles a positive float", () => { + expect( + parseEnv({ dogCount: "29.453" }, { dogCount: z.number() }), + ).toStrictEqual({ + dogCount: 29.453, + }); + }); + + it("handles a negative int", () => { + expect( + parseEnv({ dogCount: "-32" }, { dogCount: z.number() }), + ).toStrictEqual({ + dogCount: -32, + }); + }); + + it("handles a negative float", () => { + expect( + parseEnv({ dogCount: "-329.3" }, { dogCount: z.number() }), + ).toStrictEqual({ + dogCount: -329.3, + }); + }); + + it("throws on a number schema given a string with leading dash", () => { + expect(() => + parseEnv({ dogCount: "-323hello3.3942" }, { dogCount: z.number() }), + ).toThrow(); + }); + + it("handles an object", () => { + const animal = z.object({ + sound: z.string(), + size: z.enum(["big", "medium", "small"]), + }); + + const x = parseEnv( + { + ANIMALS: + '{ "dog": { "sound": "woof", "size": "big" }, "cat": { "sound": "meow", "size": "small" } }', + }, + { + ANIMALS: z.object({ + dog: animal, + cat: animal, + }), + }, + ); + + expect(x).toStrictEqual({ + ANIMALS: { + dog: { + sound: "woof", + size: "big", + }, + cat: { + sound: "meow", + size: "small", + }, + }, + }); + }); + + it("handles an intersection type", () => { + const animal = z.object({ + sound: z.string(), + size: z.enum(["big", "medium", "small"]), + }); + + const thingWithSmell = z.object({ + smell: z.enum(["bad", "very bad"]), + }); + + const x = parseEnv( + { pet: '{ "sound": "woof", "size": "big", "smell": "bad" }' }, + { pet: z.intersection(animal, thingWithSmell) }, + ); + + expect(x).toStrictEqual({ + pet: { sound: "woof", size: "big", smell: "bad" }, + }); + + expect(() => + parseEnv( + { pet: '{ "sound": "woof", "size": "big" }' }, + { pet: z.intersection(animal, thingWithSmell) }, + ), + ).toThrow(); + }); + + it("validates and throws on invalid env values", () => { + // TODO: use more specific throw matcher + expect(() => + parseEnv( + { + HOST: "localhost", + PORT: "50505050", + }, + { + HOST: z.string(), + PORT: port(), + }, + ), + ).toThrow(); + }); + + it("doesn't pass through any env values not in the schema", () => { + const x = parseEnv( + { HOST: "localhost", PORT: "5050" }, + { HOST: z.string() }, + ); + + expect(x).toStrictEqual({ + HOST: "localhost", + }); + }); + + it("handles a detailed spec with defaults", () => { + const x = parseEnv( + { + HOST: "localhost", + }, + { + HOST: z.string(), + PORT: { + schema: port(), + defaults: { _: 4040 }, + }, + }, + ); + + expect(x).toStrictEqual({ + HOST: "localhost", + PORT: 4040, + }); + }); + + it("validates defaults against the schema and throws", () => { + // TODO: use more specific throw matcher + expect(() => + parseEnv( + { + HOST: "localhost", + }, + { + HOST: z.string(), + PORT: { + schema: port(), + defaults: { _: 70000 }, + }, + }, + ), + ).toThrow(); + }); + + it("handles a simple schema with a .transform postprocessor", () => { + const x = parseEnv( + { + FUN_LEVEL: "8", + }, + { + FUN_LEVEL: z + .number() + .int() + .transform((n) => String(n + 10)), + }, + ); + + // @ts-expect-error (2322) -- shouldn't be assignable to number + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const fun: number = x.FUN_LEVEL; + + expect(x.FUN_LEVEL).toBe("18"); + }); + + it("handles a spec with a .transform postprocessor and defaults", () => { + const x = parseEnv( + {}, + { + FUN_LEVEL: { + schema: z + .number() + .int() + .transform((n) => String(n + 10)), + defaults: { _: 8 }, + }, + }, + ); + + const funLevel: string = x.FUN_LEVEL; + + // @ts-expect-error (2322) -- shouldn't be assignable to number + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const funLevelAsNumber: number = x.FUN_LEVEL; + + expect(funLevel).toBe("18"); + }); + + it("throws on a .transform postprocessor with invalid default type", () => { + expect(() => + parseEnv( + {}, + { + FUN_LEVEL: { + schema: z + .number() + .int() + .transform((n) => String(n)), + defaults: { + // @ts-expect-error (2322) -- should be number + _: new Map(), + }, + // @ts-expect-error (2322) -- excess properties should be checked + nonsense: "oops", + }, + }, + ), + ).toThrow(); + }); + + const schemasWithDefaults: [z.ZodType, any][] = [ + [z.number(), 5], + [z.object({ a: z.string(), b: z.bigint() }), { a: "ok", b: 4n }], + ]; + + it("handles spec-provided defaults for common schema types", () => { + expect.hasAssertions(); + + for (const [schema, defaultValue] of schemasWithDefaults) { + const { SOME_SCHEMA } = parseEnv( + {}, + { SOME_SCHEMA: { schema, defaults: { _: defaultValue } } as any }, + ); + + expect(SOME_SCHEMA).toStrictEqual(defaultValue); + } + }); + + it("handles validator-provided defaults for common schema types", () => { + expect.hasAssertions(); + + for (const [schema, defaultValue] of schemasWithDefaults) { + const { SOME_SCHEMA } = parseEnv( + {}, + { SOME_SCHEMA: schema.default(defaultValue) }, + ); + + expect(SOME_SCHEMA).toStrictEqual(defaultValue); + } + }); + + it("handles undefined in production and the given default otherwise", () => { + const schema = { + COOL: { + schema: z.string().nonempty(), + defaults: { + production: undefined, + _: "verycool", + }, + }, + }; + + const expected = { COOL: "verycool" }; + + expect(parseEnv({}, schema)).toStrictEqual(expected); + expect(parseEnv({ NODE_ENV: "dev" }, schema)).toStrictEqual(expected); + expect(() => parseEnv({ NODE_ENV: "production" }, schema)).toThrow(); + }); + + it("throws when a value is passed to z.undefined()", () => { + expect(() => + parseEnv({ DEPRECATED: "something" }, { DEPRECATED: z.undefined() }), + ).toThrow(); + + expect(() => + parseEnv({ DEPRECATED: "" }, { DEPRECATED: z.undefined() }), + ).toThrow(); + + expect(() => parseEnv({}, { DEPRECATED: z.undefined() })).not.toThrow(); + }); + + it("handles a schema with refinement type", () => { + const schema = { + AT_LEAST_FIVE_WORDS: z.string().refine((s) => s.split(" ").length >= 5), + }; + + expect(() => + parseEnv({ AT_LEAST_FIVE_WORDS: "only four words here" }, schema), + ).toThrow(); + + expect(() => + parseEnv({ AT_LEAST_FIVE_WORDS: "but there's five words here!" }, schema), + ).not.toThrow(); + }); + + it("handles a schema that transforms the result", () => { + const res = parseEnv( + { + wordList: "hello there friends", + }, + { + wordList: z.string().transform((s) => s.split(" ")), + }, + ); + + expect(res).toStrictEqual({ wordList: ["hello", "there", "friends"] }); + }); +}); diff --git a/src/v4/parse-env.ts b/src/v4/parse-env.ts new file mode 100644 index 0000000..a5e6e31 --- /dev/null +++ b/src/v4/parse-env.ts @@ -0,0 +1,162 @@ +import { $ZodTypes, $ZodType, $ZodDefault } from "zod/v4/core"; +import { getSchemaWithPreprocessor } from "./preprocessors.js"; +import { ErrorWithContext, Reporter, TokenFormatters } from "../reporter.js"; +import { resolveDefaultValueForSpec } from "../shared/utils.js"; +import type * as z from "zod/v4"; +import type { DeepReadonlyObject } from "../util/type-helpers.js"; + +export type SimpleSchema = $ZodType; + +export type DetailedSpec = + TSchema extends SimpleSchema + ? { + /** + * The Zod schema that will be used to parse the passed environment value + * (or any provided default). + */ + schema: TSchema; + + /** + * An object that maps `NODE_ENV` values to default values to pass to the + * schema for this var when the var isn't defined in the environment. For + * example, you could specify `{ production: "my.cool.website", + * development: "localhost:9021" }` to use a local hostname in + * development. + * + * A special key for this object is `_`, which means "the default when + * `NODE_ENV` isn't defined or doesn't match any other provided default." + * + * You can also use `.default()` in a Zod schema to provide a default. + * (For example, `z.number().gte(20).default(50)`.) + */ + defaults?: Record; + } + : never; + +export type Schemas = Record; + +type DetailedSpecKeys = keyof DetailedSpec; + +// There's some trickiness with the function parameter where in some +// circumstances excess parameters are allowed, and this strange-looking type +// fixes it. +export type RestrictSchemas = { + [K in keyof T]: T[K] extends SimpleSchema + ? SimpleSchema + : T[K] extends DetailedSpec + ? DetailedSpec & + Omit, DetailedSpecKeys> + : never; +}; + +export type ParsedSchema = T extends any + ? { + [K in keyof T]: T[K] extends SimpleSchema + ? TOut + : T[K] extends DetailedSpec + ? T[K]["schema"] extends SimpleSchema + ? TOut + : never + : never; + } + : never; + +export type ParseEnv = >( + env: Record, + schemas: T, + reporterOrTokenFormatters?: Reporter | TokenFormatters +) => DeepReadonlyObject>; + +const handleDeprecatedFields = (schema: z.ZodType) => { + const meta = schema.meta(); + if (meta?.deprecated) { + // TODO: Output deprecation hint + } +}; + +/** + * Parses the passed environment object using the provided map of Zod schemas + * and returns the immutably-typed, parsed environment. + * + * This version of `parseEnv` is intended for internal use and requires a + * reporter or token formatters to be passed in. The versions exported in + * `index.js` and `compat.js` provide defaults for this third parameter, making + * it optional. + */ +export function parseEnvImpl>( + env: Record, + schemas: T +): DeepReadonlyObject> { + const parsed: Record = {} as DeepReadonlyObject< + ParsedSchema + >; + + const errors: ErrorWithContext[] = []; + + for (const [key, schemaOrSpec] of Object.entries(schemas)) { + const envValue = env[key]; + + let defaultUsed = false; + let defaultValue: unknown; + try { + if (schemaOrSpec instanceof $ZodType) { + if (envValue == null && schemaOrSpec instanceof $ZodDefault) { + defaultUsed = true; + const spec = schemaOrSpec._zod; + defaultValue = spec.def.defaultValue; + // we "unwrap" the default value ourselves and pass it to the schema. + // in the very unlikely case that the value isn't stable AND + // validation fails, this ensures the default value we report is the + // one that was actually used. + // (consider `z.number().gte(0.5).default(() => Math.random())` -- if + // we invoked the default getter and got 0.7, and then ran the parser + // against a missing env var and it generated another default of 0.4, + // we'd report a default value that _should_ have passed.) + parsed[key] = (spec.def.innerType as z.ZodType).parse(defaultValue); + } else { + handleDeprecatedFields(schemaOrSpec as z.ZodType); + parsed[key] = getSchemaWithPreprocessor( + schemaOrSpec as $ZodTypes + ).parse(envValue, {}); + } + } else if (envValue == null) { + handleDeprecatedFields(schemaOrSpec.schema as z.ZodType); + [defaultUsed, defaultValue] = resolveDefaultValueForSpec( + schemaOrSpec.defaults, + env["NODE_ENV"] + ); + + if (defaultUsed) { + parsed[key] = (schemaOrSpec.schema as z.ZodType).parse(defaultValue); + } else { + // if there's no default, pass our envValue through the + // schema-with-preprocessor (it's an edge case, but our schema might + // accept `null`, and the preprocessor will convert `undefined` to + // `null` for us). + parsed[key] = getSchemaWithPreprocessor( + schemaOrSpec.schema as $ZodTypes + ).parse(envValue, {}); + } + } else { + parsed[key] = getSchemaWithPreprocessor( + schemaOrSpec.schema as $ZodTypes + ).parse(envValue, {}); + } + } catch (e) { + errors.push({ + key, + receivedValue: envValue, + error: e, + defaultUsed, + defaultValue + }); + } + } + + if (errors.length > 0) { + // TODO: Implement reporting... + throw new Error(`Got errors`); + } + + return parsed as DeepReadonlyObject>; +} diff --git a/src/v4/preprocessors.ts b/src/v4/preprocessors.ts new file mode 100644 index 0000000..5b72be2 --- /dev/null +++ b/src/v4/preprocessors.ts @@ -0,0 +1,139 @@ +import * as z from "zod/v4"; +import { assertNever } from "../util/type-helpers.js"; +import { + bigInt, + boolean, + date, + identity, + json, + nullProcessor, + number, + throwIfUnknown, + throwIfCurrentlyUnsupported, + throwIfWillNeverBeSupported, +} from "../shared/processing.js"; +import type * as zCore from "zod/v4/core"; + +/** + * Given a Zod schema, returns a function that tries to convert a string (or + * undefined!) to a valid input type for the schema. + */ +export function getPreprocessorByZodType( + schema: zCore.$ZodTypes, +): (arg: string | undefined) => unknown { + const { def } = schema._zod; + + switch (def.type) { + case "pipe": + return getPreprocessorByZodType(def.in as zCore.$ZodTypes); + case "string": + case "enum": + case "undefined": + return identity; + + case "number": + return number; + + case "bigint": + return bigInt; + + case "boolean": + return boolean; + + case "array": + case "object": + case "tuple": + case "record": + case "intersection": + return json; + + case "default": + return getPreprocessorByZodType(def.innerType as zCore.$ZodTypes); + + case "optional": { + const { innerType } = def; + const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); + return (arg) => { + if (arg === undefined) return arg; + return pp(arg); + }; + } + + case "nullable": { + const { innerType } = def; + const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); + return (arg) => { + // coerce undefined to null. + if (arg == null) return null; + return pp(arg); + }; + } + + case "date": + return date; + + case "literal": + switch (typeof def.values?.[0]) { + case "number": + return getPreprocessorByZodType({ + _zod: { def: { type: "number" } }, + } as zCore.$ZodTypes); + case "string": + return getPreprocessorByZodType({ + _zod: { def: { type: "string" } }, + } as zCore.$ZodTypes); + case "boolean": + return getPreprocessorByZodType({ + _zod: { def: { type: "boolean" } }, + } as zCore.$ZodTypes); + default: + return (arg) => arg; + } + + case "null": + return nullProcessor; + + case "union": + return throwIfCurrentlyUnsupported(def.type); + + case "any": + case "unknown": + return throwIfUnknown(def.type); + + // some of these types could maybe be supported (if only via the identity + // function), but don't necessarily represent something meaningful as a + // top-level schema passed to znv. + case "success": + case "catch": + case "nan": + case "template_literal": + case "custom": + case "nonoptional": + case "prefault": + case "transform": + case "file": + case "void": + case "never": + case "lazy": + case "promise": + case "map": + case "set": + case "symbol": + case "readonly": + return throwIfWillNeverBeSupported(def.type); + default: { + assertNever(def); + } + } +} + +/** + * Given a Zod schema, return the schema wrapped in a preprocessor that tries to + * convert a string to the schema's input type. + */ +export function getSchemaWithPreprocessor(schema: zCore.$ZodTypes) { + return z.preprocess( + getPreprocessorByZodType(schema) as (arg: unknown) => unknown, + schema, + ); +} From 5267b9f2ac06cd20b1e7b10bc148c49589f274ae Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Sun, 13 Jul 2025 21:49:35 +0200 Subject: [PATCH 2/9] chore: prettier --- src/v4/parse-env.ts | 74 ++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/v4/parse-env.ts b/src/v4/parse-env.ts index a5e6e31..6a72fd1 100644 --- a/src/v4/parse-env.ts +++ b/src/v4/parse-env.ts @@ -10,27 +10,27 @@ export type SimpleSchema = $ZodType; export type DetailedSpec = TSchema extends SimpleSchema ? { - /** - * The Zod schema that will be used to parse the passed environment value - * (or any provided default). - */ - schema: TSchema; - - /** - * An object that maps `NODE_ENV` values to default values to pass to the - * schema for this var when the var isn't defined in the environment. For - * example, you could specify `{ production: "my.cool.website", - * development: "localhost:9021" }` to use a local hostname in - * development. - * - * A special key for this object is `_`, which means "the default when - * `NODE_ENV` isn't defined or doesn't match any other provided default." - * - * You can also use `.default()` in a Zod schema to provide a default. - * (For example, `z.number().gte(20).default(50)`.) - */ - defaults?: Record; - } + /** + * The Zod schema that will be used to parse the passed environment value + * (or any provided default). + */ + schema: TSchema; + + /** + * An object that maps `NODE_ENV` values to default values to pass to the + * schema for this var when the var isn't defined in the environment. For + * example, you could specify `{ production: "my.cool.website", + * development: "localhost:9021" }` to use a local hostname in + * development. + * + * A special key for this object is `_`, which means "the default when + * `NODE_ENV` isn't defined or doesn't match any other provided default." + * + * You can also use `.default()` in a Zod schema to provide a default. + * (For example, `z.number().gte(20).default(50)`.) + */ + defaults?: Record; + } : never; export type Schemas = Record; @@ -45,26 +45,26 @@ export type RestrictSchemas = { ? SimpleSchema : T[K] extends DetailedSpec ? DetailedSpec & - Omit, DetailedSpecKeys> + Omit, DetailedSpecKeys> : never; }; export type ParsedSchema = T extends any ? { - [K in keyof T]: T[K] extends SimpleSchema - ? TOut - : T[K] extends DetailedSpec - ? T[K]["schema"] extends SimpleSchema - ? TOut - : never - : never; - } + [K in keyof T]: T[K] extends SimpleSchema + ? TOut + : T[K] extends DetailedSpec + ? T[K]["schema"] extends SimpleSchema + ? TOut + : never + : never; + } : never; export type ParseEnv = >( env: Record, schemas: T, - reporterOrTokenFormatters?: Reporter | TokenFormatters + reporterOrTokenFormatters?: Reporter | TokenFormatters, ) => DeepReadonlyObject>; const handleDeprecatedFields = (schema: z.ZodType) => { @@ -85,7 +85,7 @@ const handleDeprecatedFields = (schema: z.ZodType) => { */ export function parseEnvImpl>( env: Record, - schemas: T + schemas: T, ): DeepReadonlyObject> { const parsed: Record = {} as DeepReadonlyObject< ParsedSchema @@ -116,14 +116,14 @@ export function parseEnvImpl>( } else { handleDeprecatedFields(schemaOrSpec as z.ZodType); parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec as $ZodTypes + schemaOrSpec as $ZodTypes, ).parse(envValue, {}); } } else if (envValue == null) { handleDeprecatedFields(schemaOrSpec.schema as z.ZodType); [defaultUsed, defaultValue] = resolveDefaultValueForSpec( schemaOrSpec.defaults, - env["NODE_ENV"] + env["NODE_ENV"], ); if (defaultUsed) { @@ -134,12 +134,12 @@ export function parseEnvImpl>( // accept `null`, and the preprocessor will convert `undefined` to // `null` for us). parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec.schema as $ZodTypes + schemaOrSpec.schema as $ZodTypes, ).parse(envValue, {}); } } else { parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec.schema as $ZodTypes + schemaOrSpec.schema as $ZodTypes, ).parse(envValue, {}); } } catch (e) { @@ -148,7 +148,7 @@ export function parseEnvImpl>( receivedValue: envValue, error: e, defaultUsed, - defaultValue + defaultValue, }); } } From 8c5275679a25fcc4e3f4ef2e288aac650d5809fb Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Thu, 17 Jul 2025 23:37:49 +0200 Subject: [PATCH 3/9] fix: support zod 4 only --- .prettierignore | 1 + package.json | 4 +- pnpm-lock.yaml | 31 +- src/__snapshots__/reporter.test.ts.snap | 31 ++ src/bridge.test.ts | 68 --- src/bridge.ts | 47 -- src/extra-schemas.test.ts | 2 +- src/extra-schemas.ts | 4 +- src/parse-env.test.ts | 6 +- src/parse-env.ts | 71 ++- src/preprocessors.ts | 124 +++--- src/reporter.test.ts | 47 ++ src/reporter.ts | 73 +-- src/v4/extra-schemas.test.ts | 18 - src/v4/extra-schemas.ts | 8 - src/v4/parse-env.test.ts | 570 ------------------------ src/v4/parse-env.ts | 162 ------- src/v4/preprocessors.ts | 139 ------ 18 files changed, 238 insertions(+), 1168 deletions(-) create mode 100644 .prettierignore create mode 100644 src/__snapshots__/reporter.test.ts.snap delete mode 100644 src/bridge.test.ts delete mode 100644 src/bridge.ts create mode 100644 src/reporter.test.ts delete mode 100644 src/v4/extra-schemas.test.ts delete mode 100644 src/v4/extra-schemas.ts delete mode 100644 src/v4/parse-env.test.ts delete mode 100644 src/v4/parse-env.ts delete mode 100644 src/v4/preprocessors.ts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b05c2df --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +__snapshots__ diff --git a/package.json b/package.json index 3f1fe31..a146486 100644 --- a/package.json +++ b/package.json @@ -65,10 +65,10 @@ "prepublishOnly": "run-s -l test build" }, "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" + "zod": "^4.0.0" }, "devDependencies": { - "@arethetypeswrong/cli": "^0.17.4", + "@arethetypeswrong/cli": "^0.18.2", "@types/jest": "^29.5.14", "@types/node": "^22.13.10", "eslint": "^9.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f6f3dd..e4fc012 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@arethetypeswrong/cli': - specifier: ^0.17.4 - version: 0.17.4 + specifier: ^0.18.2 + version: 0.18.2 '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -54,14 +54,14 @@ packages: '@andrewbranch/untar.js@1.0.3': resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} - '@arethetypeswrong/cli@0.17.4': - resolution: {integrity: sha512-AeiKxtf67XD/NdOqXgBOE5TZWH3EOCt+0GkbUpekOzngc+Q/cRZ5azjWyMxISxxfp0EItgm5NoSld9p7BAA5xQ==} - engines: {node: '>=18'} + '@arethetypeswrong/cli@0.18.2': + resolution: {integrity: sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw==} + engines: {node: '>=20'} hasBin: true - '@arethetypeswrong/core@0.17.4': - resolution: {integrity: sha512-Izvir8iIoU+X4SKtDAa5kpb+9cpifclzsbA8x/AZY0k0gIfXYQ1fa1B6Epfe6vNA2YfDX8VtrZFgvnXB6aPEoQ==} - engines: {node: '>=18'} + '@arethetypeswrong/core@0.18.2': + resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} + engines: {node: '>=20'} '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} @@ -1833,8 +1833,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2644,9 +2645,9 @@ snapshots: '@andrewbranch/untar.js@1.0.3': {} - '@arethetypeswrong/cli@0.17.4': + '@arethetypeswrong/cli@0.18.2': dependencies: - '@arethetypeswrong/core': 0.17.4 + '@arethetypeswrong/core': 0.18.2 chalk: 4.1.2 cli-table3: 0.6.5 commander: 10.0.1 @@ -2654,13 +2655,13 @@ snapshots: marked-terminal: 7.3.0(marked@9.1.6) semver: 7.7.1 - '@arethetypeswrong/core@0.17.4': + '@arethetypeswrong/core@0.18.2': dependencies: '@andrewbranch/untar.js': 1.0.3 '@loaderkit/resolve': 1.0.4 cjs-module-lexer: 1.4.3 fflate: 0.8.2 - lru-cache: 10.4.3 + lru-cache: 11.1.0 semver: 7.7.1 typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 @@ -4966,7 +4967,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@10.4.3: {} + lru-cache@11.1.0: {} lru-cache@5.1.1: dependencies: diff --git a/src/__snapshots__/reporter.test.ts.snap b/src/__snapshots__/reporter.test.ts.snap new file mode 100644 index 0000000..1621703 --- /dev/null +++ b/src/__snapshots__/reporter.test.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reporter should test the error output 1`] = ` +"Errors found while parsing environment: +[CONFIG_OBJECT]: This is a very special object that holds configuration. + ✖ Invalid input: expected string, received number + → at a + ✖ Invalid input: expected number, received undefined + → at b + ✖ Invalid input: expected string, received number + → at c.d + (received "{\\"a\\":123,\\"c\\":{\\"d\\":21}}") + +[FOO]: + ✖ Invalid input: expected number, received string + (received "bar") + +[SOME_WITH_DEPRECATED_DESCRIPTION]: Some description + ✖ Invalid input: expected string, received undefined + (received undefined) + +[SOME_WITH_DEPRECATED_DESCRIPTION_AND_NEW_DESCRIPTION]: New description + ✖ Invalid input: expected string, received undefined + (received undefined) + +[PORT]: + ✖ Too big: expected number to be <=65535 + (received undefined) + (used default of 70000) +" +`; diff --git a/src/bridge.test.ts b/src/bridge.test.ts deleted file mode 100644 index 9fe3f71..0000000 --- a/src/bridge.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as z3 from "zod/v3"; -import * as z4 from "zod/v4"; -import { parseEnv } from "./bridge.js"; - -describe("zod3 and zod4 support", () => { - it("should support zod3", () => { - const x = parseEnv( - { - HOST: "localhost", - }, - { - HOST: z3.string(), - }, - ); - expect(x).toStrictEqual({ - HOST: "localhost", - }); - }); - it("should support zod3 with extended schemas", () => { - const x = parseEnv( - { - HOST: "localhost", - }, - { - HOST: z3.string(), - PORT: { - schema: z3.number(), - defaults: { _: 4040 }, - }, - }, - ); - expect(x).toStrictEqual({ - HOST: "localhost", - PORT: 4040, - }); - }); - it("should support zod4", () => { - const x = parseEnv( - { - HOST: "localhost", - }, - { - HOST: z4.string(), - }, - ); - expect(x).toStrictEqual({ - HOST: "localhost", - }); - }); - it("should support zod4 with extended schemas", () => { - const x = parseEnv( - { - HOST: "localhost", - }, - { - HOST: z4.string(), - PORT: { - schema: z4.number(), - defaults: { _: 4040 }, - }, - }, - ); - expect(x).toStrictEqual({ - HOST: "localhost", - PORT: 4040, - }); - }); -}); diff --git a/src/bridge.ts b/src/bridge.ts deleted file mode 100644 index 5b994a0..0000000 --- a/src/bridge.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - parseEnvImpl as parseEnvImplV4, - ParsedSchema as ParsedSchemaV4, - RestrictSchemas as RestrictSchemasV4, - Schemas as SchemasV4, -} from "./v4/parse-env.js"; -import { - parseEnv as parseEnvImplV3, - ParsedSchema as ParsedSchemaV3, - RestrictSchemas as RestrictSchemasV3, - Schemas as SchemasV3, -} from "./index.js"; -import type { DeepReadonlyObject } from "./util/type-helpers.js"; - -const isZodV4Schema = ( - schema: Record, -): schema is SchemasV4 & RestrictSchemasV4 => { - const firstKey = Object.keys(schema)[0]!; - const thisSchema = schema[firstKey].schema ?? schema[firstKey]; - return "_zod" in thisSchema && typeof thisSchema._zod === "object"; -}; - -export function parseEnv< - V3SchemaT extends SchemasV3 & RestrictSchemasV3, ->( - env: Record, - schemas: V3SchemaT, -): DeepReadonlyObject>; -export function parseEnv< - V4SchemaT extends SchemasV4 & RestrictSchemasV4, ->( - env: Record, - schemas: V4SchemaT, -): DeepReadonlyObject>; -export function parseEnv( - env: Record, - schemas: Record, -) { - if (isZodV4Schema(schemas)) { - return parseEnvImplV4(env, schemas); - } - // we assume zod3 otherwise - return parseEnvImplV3( - env, - schemas as SchemasV3 & RestrictSchemasV3, - ); -} diff --git a/src/extra-schemas.test.ts b/src/extra-schemas.test.ts index 21de797..fae90a4 100644 --- a/src/extra-schemas.test.ts +++ b/src/extra-schemas.test.ts @@ -1,7 +1,7 @@ import { parseEnv } from "./index.js"; import { deprecate } from "./extra-schemas.js"; -describe("extra schemas v3", () => { +describe("extra schemas", () => { describe("deprecate", () => { it("throws when a value is passed", () => { expect(() => diff --git a/src/extra-schemas.ts b/src/extra-schemas.ts index cb0dd6d..891daf0 100644 --- a/src/extra-schemas.ts +++ b/src/extra-schemas.ts @@ -1,8 +1,8 @@ -import * as z from "zod/v3"; +import * as z from "zod/v4"; export const port = () => z.number().int().nonnegative().lte(65535); export const deprecate = () => z - .undefined({ invalid_type_error: "This var is deprecated." }) + .undefined({ error: "This var is deprecated." }) .transform(() => undefined as never); diff --git a/src/parse-env.test.ts b/src/parse-env.test.ts index d78e865..3ad29b3 100644 --- a/src/parse-env.test.ts +++ b/src/parse-env.test.ts @@ -1,11 +1,11 @@ -import * as z from "zod/v3"; +import * as z from "zod"; import { parseEnv } from "./index.js"; import { port } from "./extra-schemas.js"; // FIXME: many of these don't need to be part of parseCore tests, or at minimum // can be categorized further -describe("parseCore v3", () => { +describe("parseCore", () => { it("handles a basic case", () => { const x = parseEnv( { @@ -480,7 +480,7 @@ describe("parseCore v3", () => { ).toThrow(); }); - const schemasWithDefaults: [z.ZodTypeAny, any][] = [ + const schemasWithDefaults: [z.ZodType, any][] = [ [z.number(), 5], [z.object({ a: z.string(), b: z.bigint() }), { a: "ok", b: 4n }], ]; diff --git a/src/parse-env.ts b/src/parse-env.ts index 969df1d..76a8166 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -1,26 +1,19 @@ -import * as z from "zod/v3"; - +import { $ZodTypes, $ZodType, $ZodDefault } from "zod/v4/core"; import { getSchemaWithPreprocessor } from "./preprocessors.js"; import { + ErrorWithContext, makeDefaultReporter, + Reporter, + TokenFormatters, errorMap, - type TokenFormatters, - type ErrorWithContext, - type Reporter, } from "./reporter.js"; - import { resolveDefaultValueForSpec } from "./shared/utils.js"; +import type * as z from "zod/v4"; import type { DeepReadonlyObject } from "./util/type-helpers.js"; -export type SimpleSchema = z.ZodType< - TOut, - z.ZodTypeDef, - TIn ->; +export type SimpleSchema = $ZodType; -export type DetailedSpec< - TSchema extends SimpleSchema = SimpleSchema, -> = +export type DetailedSpec = TSchema extends SimpleSchema ? { /** @@ -32,6 +25,7 @@ export type DetailedSpec< /** * A description of this env var that's provided as help text if the * passed value fails validation, or is required but missing. + * @deprecated use e.g. `z.string().meta({ description: 'Your description' })` instead */ description?: string; @@ -80,14 +74,6 @@ export type ParsedSchema = T extends any } : never; -/** - * Mostly an internal convenience function for testing. Returns the input - * parameter unchanged, but with the same inference used in `parseEnv` applied. - */ -export const inferSchemas = >( - schemas: T, -): T & RestrictSchemas => schemas; - export type ParseEnv = >( env: Record, schemas: T, @@ -108,15 +94,14 @@ export function parseEnvImpl>( schemas: T, reporterOrTokenFormatters: Reporter | TokenFormatters, ): DeepReadonlyObject> { + const parsed: Record = {} as DeepReadonlyObject< + ParsedSchema + >; const reporter = typeof reporterOrTokenFormatters === "function" ? reporterOrTokenFormatters : makeDefaultReporter(reporterOrTokenFormatters); - const parsed: Record = {} as DeepReadonlyObject< - ParsedSchema - >; - const errors: ErrorWithContext[] = []; for (const [key, schemaOrSpec] of Object.entries(schemas)) { @@ -125,10 +110,11 @@ export function parseEnvImpl>( let defaultUsed = false; let defaultValue: unknown; try { - if (schemaOrSpec instanceof z.ZodType) { - if (envValue == null && schemaOrSpec instanceof z.ZodDefault) { + if (schemaOrSpec instanceof $ZodType) { + if (envValue == null && schemaOrSpec instanceof $ZodDefault) { defaultUsed = true; - defaultValue = schemaOrSpec._def.defaultValue(); + const spec = schemaOrSpec._zod; + defaultValue = spec.def.defaultValue; // we "unwrap" the default value ourselves and pass it to the schema. // in the very unlikely case that the value isn't stable AND // validation fails, this ensures the default value we report is the @@ -137,12 +123,13 @@ export function parseEnvImpl>( // we invoked the default getter and got 0.7, and then ran the parser // against a missing env var and it generated another default of 0.4, // we'd report a default value that _should_ have passed.) - parsed[key] = schemaOrSpec.parse(defaultValue, { errorMap }); + parsed[key] = (spec.def.innerType as z.ZodType).parse(defaultValue, { + error: errorMap, + }); } else { - parsed[key] = getSchemaWithPreprocessor(schemaOrSpec).parse( - envValue, - { errorMap }, - ); + parsed[key] = getSchemaWithPreprocessor( + schemaOrSpec as $ZodTypes, + ).parse(envValue, { error: errorMap }); } } else if (envValue == null) { [defaultUsed, defaultValue] = resolveDefaultValueForSpec( @@ -151,22 +138,20 @@ export function parseEnvImpl>( ); if (defaultUsed) { - parsed[key] = schemaOrSpec.schema.parse(defaultValue, { errorMap }); + parsed[key] = (schemaOrSpec.schema as z.ZodType).parse(defaultValue); } else { // if there's no default, pass our envValue through the // schema-with-preprocessor (it's an edge case, but our schema might // accept `null`, and the preprocessor will convert `undefined` to // `null` for us). - parsed[key] = getSchemaWithPreprocessor(schemaOrSpec.schema).parse( - envValue, - { errorMap }, - ); + parsed[key] = getSchemaWithPreprocessor( + schemaOrSpec.schema as $ZodTypes, + ).parse(envValue, { error: errorMap }); } } else { - parsed[key] = getSchemaWithPreprocessor(schemaOrSpec.schema).parse( - envValue, - { errorMap }, - ); + parsed[key] = getSchemaWithPreprocessor( + schemaOrSpec.schema as $ZodTypes, + ).parse(envValue, { error: errorMap }); } } catch (e) { errors.push({ diff --git a/src/preprocessors.ts b/src/preprocessors.ts index b628a29..35ed7cd 100644 --- a/src/preprocessors.ts +++ b/src/preprocessors.ts @@ -1,5 +1,4 @@ -import * as z from "zod/v3"; - +import * as z from "zod/v4"; import { assertNever } from "./util/type-helpers.js"; import { bigInt, @@ -13,60 +12,56 @@ import { throwIfCurrentlyUnsupported, throwIfWillNeverBeSupported, } from "./shared/processing.js"; - -const { ZodFirstPartyTypeKind: TypeName } = z; +import type * as zCore from "zod/v4/core"; /** * Given a Zod schema, returns a function that tries to convert a string (or * undefined!) to a valid input type for the schema. */ export function getPreprocessorByZodType( - schema: z.ZodFirstPartySchemaTypes, + schema: zCore.$ZodTypes, ): (arg: string | undefined) => unknown { - const def = schema._def; - const { typeName } = def; - - switch (typeName) { - case TypeName.ZodString: - case TypeName.ZodEnum: - case TypeName.ZodUndefined: + const { def } = schema._zod; + + switch (def.type) { + case "pipe": + return getPreprocessorByZodType(def.in as zCore.$ZodTypes); + case "string": + case "enum": + case "undefined": return identity; - case TypeName.ZodNumber: + case "number": return number; - case TypeName.ZodBigInt: + case "bigint": return bigInt; - case TypeName.ZodBoolean: + case "boolean": return boolean; - case TypeName.ZodArray: - case TypeName.ZodObject: - case TypeName.ZodTuple: - case TypeName.ZodRecord: - case TypeName.ZodIntersection: + case "array": + case "object": + case "tuple": + case "record": + case "intersection": return json; - case TypeName.ZodEffects: - return getPreprocessorByZodType(def.schema); + case "default": + return getPreprocessorByZodType(def.innerType as zCore.$ZodTypes); - case TypeName.ZodDefault: - // eslint-disable-next-line unicorn/consistent-destructuring -- false positive - return getPreprocessorByZodType(def.innerType); - - case TypeName.ZodOptional: { + case "optional": { const { innerType } = def; - const pp = getPreprocessorByZodType(innerType); + const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); return (arg) => { if (arg === undefined) return arg; return pp(arg); }; } - case TypeName.ZodNullable: { + case "nullable": { const { innerType } = def; - const pp = getPreprocessorByZodType(innerType); + const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); return (arg) => { // coerce undefined to null. if (arg == null) return null; @@ -74,59 +69,60 @@ export function getPreprocessorByZodType( }; } - case TypeName.ZodDate: + case "date": return date; - case TypeName.ZodLiteral: - switch (typeof def.value) { + case "literal": + switch (typeof def.values?.[0]) { case "number": return getPreprocessorByZodType({ - _def: { typeName: TypeName.ZodNumber }, - } as z.ZodFirstPartySchemaTypes); + _zod: { def: { type: "number" } }, + } as zCore.$ZodTypes); case "string": return getPreprocessorByZodType({ - _def: { typeName: TypeName.ZodString }, - } as z.ZodFirstPartySchemaTypes); + _zod: { def: { type: "string" } }, + } as zCore.$ZodTypes); case "boolean": return getPreprocessorByZodType({ - _def: { typeName: TypeName.ZodBoolean }, - } as z.ZodFirstPartySchemaTypes); + _zod: { def: { type: "boolean" } }, + } as zCore.$ZodTypes); default: return (arg) => arg; } - case TypeName.ZodNull: + case "null": return nullProcessor; - case TypeName.ZodDiscriminatedUnion: - case TypeName.ZodUnion: - case TypeName.ZodNativeEnum: - return throwIfCurrentlyUnsupported(typeName); + case "union": + return throwIfCurrentlyUnsupported(def.type); - case TypeName.ZodAny: - case TypeName.ZodUnknown: - return throwIfUnknown(typeName); + case "any": + case "unknown": + return throwIfUnknown(def.type); // some of these types could maybe be supported (if only via the identity // function), but don't necessarily represent something meaningful as a // top-level schema passed to znv. - case TypeName.ZodVoid: - case TypeName.ZodNever: - case TypeName.ZodLazy: - case TypeName.ZodFunction: - case TypeName.ZodPromise: - case TypeName.ZodMap: - case TypeName.ZodSet: - case TypeName.ZodNaN: - case TypeName.ZodCatch: - case TypeName.ZodBranded: - case TypeName.ZodPipeline: - case TypeName.ZodSymbol: - case TypeName.ZodReadonly: - return throwIfWillNeverBeSupported(typeName); - + case "success": + case "catch": + case "nan": + case "template_literal": + case "custom": + case "nonoptional": + case "prefault": + case "transform": + case "file": + case "void": + case "never": + case "lazy": + case "promise": + case "map": + case "set": + case "symbol": + case "readonly": + return throwIfWillNeverBeSupported(def.type); default: { - assertNever(typeName); + assertNever(def); } } } @@ -135,7 +131,7 @@ export function getPreprocessorByZodType( * Given a Zod schema, return the schema wrapped in a preprocessor that tries to * convert a string to the schema's input type. */ -export function getSchemaWithPreprocessor(schema: z.ZodTypeAny) { +export function getSchemaWithPreprocessor(schema: zCore.$ZodTypes) { return z.preprocess( getPreprocessorByZodType(schema) as (arg: unknown) => unknown, schema, diff --git a/src/reporter.test.ts b/src/reporter.test.ts new file mode 100644 index 0000000..1d3cb13 --- /dev/null +++ b/src/reporter.test.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { parseEnv, port } from "./compat.js"; + +describe("reporter", () => { + it("should test the error output", () => { + expect(() => + parseEnv( + { + FOO: "bar", + CONFIG_OBJECT: JSON.stringify({ a: 123, c: { d: 21 } }), + DO_NOT_USE_THIS_KEY: "this value might not exist", + }, + { + CONFIG_OBJECT: z + .object({ + a: z.string(), + b: z.number(), + c: z.object({ + d: z.string(), + }), + }) + .meta({ + description: + "This is a very special object that holds configuration.", + }), + BLA: z.string().default("bar"), + FOO: z.number(), + DO_NOT_USE_THIS_KEY: z.string().meta({ + deprecated: true, + }), + SOME_WITH_DEPRECATED_DESCRIPTION: { + description: "Some description", + schema: z.string(), + }, + SOME_WITH_DEPRECATED_DESCRIPTION_AND_NEW_DESCRIPTION: { + description: "Some description", + schema: z.string().meta({ description: "New description" }), + }, + PORT: { + schema: port(), + defaults: { _: 70000 }, + }, + }, + ), + ).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/reporter.ts b/src/reporter.ts index 417eeef..08e4d4c 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -1,19 +1,17 @@ -import { ZodError, ZodErrorMap, ZodIssueCode } from "zod/v3"; +import { $ZodError, $ZodRawIssue, $ZodErrorMap, toDotPath } from "zod/v4/core"; import type { Schemas } from "./parse-env.js"; +import type * as z from "zod/v4"; // Even though we also have our own formatter, we pass a custom error map to // Zod's `.parse()` for two reasons: // - to ensure that no other consumer of zod in the codebase has set a default // error map that might override our formatting // - to return slightly friendlier error messages in some common scenarios. -export const errorMap: ZodErrorMap = (issue, ctx) => { - if ( - issue.code === ZodIssueCode.invalid_type && - issue.received === "undefined" - ) { - return { message: "This field is required." }; +export const errorMap: $ZodErrorMap = (issue: $ZodRawIssue) => { + if (issue.code === "invalid_type" && issue.input === "undefined") { + return "This field is required."; } - return { message: ctx.defaultError }; + return undefined; }; export interface ErrorWithContext { @@ -57,14 +55,37 @@ export function makeDefaultReporter(formatters: TokenFormatters) { return reporter; } +// this is zod's `prettifyError`, but with formatting for the object path. +function prettifyError( + error: $ZodError, + formatObjectPath: (string: string) => string = String, +): string { + const lines: string[] = []; + // sort by path length + const issues = [...error.issues].sort( + (a, b) => a.path.length - b.path.length, + ); + + // Process each issue + for (const issue of issues) { + lines.push(`✖ ${issue.message}`); + if (issue.path?.length) { + lines.push(`→ at ${formatObjectPath(toDotPath(issue.path))}`); + } + } + + // Convert Map to formatted string + return lines.map((l) => indent(l, 2)).join("\n"); +} + export function reportErrors( errors: ErrorWithContext[], schemas: Schemas, { formatVarName = String, - formatObjKey = String, formatReceivedValue = String, formatDefaultValue = String, + formatObjKey = String, formatHeader = String, }: TokenFormatters = {}, ): string { @@ -72,26 +93,26 @@ export function reportErrors( ({ key, receivedValue, error, defaultUsed, defaultValue }) => { let title = `[${formatVarName(key)}]:`; - const desc = schemas[key]?.description; + const typeSchema = ( + schemas[key] && "schema" in schemas[key] + ? schemas[key].schema + : schemas[key] + ) as z.ZodType; + const meta = typeSchema.meta(); + const desc = + meta?.description ?? + (schemas[key] && "description" in schemas[key] + ? // eslint-disable-next-line @typescript-eslint/no-deprecated + schemas[key].description + : undefined); if (desc) { title += ` ${desc}`; } const message: string[] = [title]; - if (error instanceof ZodError) { - const { formErrors, fieldErrors } = error.flatten(); - for (const fe of formErrors) message.push(indent(fe, 2)); - const fieldErrorEntries = Object.entries(fieldErrors); - if (fieldErrorEntries.length > 0) { - message.push(indent("Errors on object keys:", 2)); - for (const [objKey, keyErrors] of fieldErrorEntries) { - message.push(indent(`[${formatObjKey(objKey)}]:`, 4)); - if (keyErrors) { - for (const fe of keyErrors) message.push(indent(fe, 6)); - } - } - } + if (error instanceof $ZodError) { + message.push(prettifyError(error, formatObjKey)); } else if (error instanceof Error) { message.push(...error.message.split("\n").map((l) => indent(l, 2))); } else { @@ -109,7 +130,7 @@ export function reportErrors( ? "undefined" : JSON.stringify(receivedValue), )})`, - 2, + 4, ), ); @@ -121,12 +142,12 @@ export function reportErrors( ? "undefined" : JSON.stringify(defaultValue), )})`, - 2, + 4, ), ); } - return message.map((l) => indent(l, 2)).join("\n"); + return message.join("\n"); }, ); diff --git a/src/v4/extra-schemas.test.ts b/src/v4/extra-schemas.test.ts deleted file mode 100644 index 2c5b0d4..0000000 --- a/src/v4/extra-schemas.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { parseEnvImpl as parseEnv } from "./parse-env.js"; -import { deprecate } from "./extra-schemas.js"; - -describe("extra schemas v4", () => { - describe("deprecate", () => { - it("throws when a value is passed", () => { - expect(() => - parseEnv({ DEPRECATED: "something" }, { DEPRECATED: deprecate() }), - ).toThrow(); - - expect(() => - parseEnv({ DEPRECATED: "" }, { DEPRECATED: deprecate() }), - ).toThrow(); - - expect(() => parseEnv({}, { DEPRECATED: deprecate() })).not.toThrow(); - }); - }); -}); diff --git a/src/v4/extra-schemas.ts b/src/v4/extra-schemas.ts deleted file mode 100644 index 891daf0..0000000 --- a/src/v4/extra-schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as z from "zod/v4"; - -export const port = () => z.number().int().nonnegative().lte(65535); - -export const deprecate = () => - z - .undefined({ error: "This var is deprecated." }) - .transform(() => undefined as never); diff --git a/src/v4/parse-env.test.ts b/src/v4/parse-env.test.ts deleted file mode 100644 index fc13ea9..0000000 --- a/src/v4/parse-env.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import * as z from "zod/v4"; - -import { parseEnvImpl as parseEnv } from "./parse-env.js"; -import { port } from "./extra-schemas.js"; - -// FIXME: many of these don't need to be part of parseCore tests, or at minimum -// can be categorized further -describe("parseCore v4", () => { - it("handles a basic case", () => { - const x = parseEnv( - { - HOST: "localhost", - PORT: "5050", - }, - { - HOST: z.string(), - PORT: port(), - }, - ); - - expect(x).toStrictEqual({ - HOST: "localhost", - PORT: 5050, - }); - }); - - it("handles a basic case with a zod default", () => { - const x = parseEnv( - { - PORT: "5050", - }, - { - HOST: z.string().default("localhost"), - PORT: port(), - }, - ); - - expect(x).toStrictEqual({ - HOST: "localhost", - PORT: 5050, - }); - }); - - it("returns the correct default based on NODE_ENV", () => { - expect( - parseEnv( - { - NODE_ENV: "production", - HOST: "envhost", - PORT: "5050", - }, - { - HOST: { - schema: z.string(), - defaults: { - production: "prodhost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "envhost", - PORT: 5050, - }); - - expect( - parseEnv( - { - NODE_ENV: "production", - }, - { - HOST: { - schema: z.string(), - defaults: { - production: "prodhost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "prodhost", - PORT: 80, - }); - - expect( - parseEnv( - { - NODE_ENV: "production", - HOST: "envhost", - }, - { - HOST: { - schema: z.string(), - defaults: { - development: "devhost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "envhost", - PORT: 80, - }); - - expect( - parseEnv( - { - NODE_ENV: "production", - }, - { - HOST: { - schema: z.string(), - defaults: { - production: "prodhost", - development: "devhost", - _: "defaulthost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "prodhost", - PORT: 80, - }); - - expect( - parseEnv( - { - NODE_ENV: "production", - HOST: "envhost", - }, - { - HOST: { - schema: z.string(), - defaults: { - production: "prodhost", - development: "devhost", - _: "defaulthost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "envhost", - PORT: 80, - }); - - expect( - parseEnv( - {}, - { - HOST: { - schema: z.string(), - defaults: { - production: "prodhost", - development: "devhost", - _: "defaulthost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "defaulthost", - PORT: 80, - }); - - expect( - parseEnv( - {}, - { - HOST: { - schema: z.string().default("zoddefaulthost"), - defaults: { - production: "prodhost", - development: "devhost", - _: "defaulthost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "defaulthost", - PORT: 80, - }); - - expect( - parseEnv( - {}, - { - HOST: { - schema: z.string().default("zoddefaulthost"), - defaults: { - production: "prodhost", - development: "devhost", - }, - }, - PORT: port().default(80), - }, - ), - ).toStrictEqual({ - HOST: "zoddefaulthost", - PORT: 80, - }); - }); - - it("handles optional values", () => { - const res = parseEnv( - { dogs: "12" }, - { - cats: z.number().nonnegative().optional(), - dogs: z.bigint().optional(), - }, - ); - - expect(res).toStrictEqual({ - cats: undefined, - dogs: 12n, - }); - }); - - it("handles an optional nonempty string", () => { - expect( - parseEnv( - {}, - { - myValue: z.string().nonempty().optional(), - }, - ), - ).toStrictEqual({ - myValue: undefined, - }); - - expect(() => - parseEnv( - { myValue: "" }, - { - myValue: z.string().nonempty().optional(), - }, - ), - ).toThrow(); - }); - - it("fails a string schema when value is missing", () => { - // a missing value should never be coerced to an empty string, for example - expect(() => parseEnv({}, { dogs: z.string() })).toThrow(); - }); - - it("handles a positive int", () => { - expect( - parseEnv({ dogCount: "34" }, { dogCount: z.number() }), - ).toStrictEqual({ - dogCount: 34, - }); - }); - - it("handles a positive float", () => { - expect( - parseEnv({ dogCount: "29.453" }, { dogCount: z.number() }), - ).toStrictEqual({ - dogCount: 29.453, - }); - }); - - it("handles a negative int", () => { - expect( - parseEnv({ dogCount: "-32" }, { dogCount: z.number() }), - ).toStrictEqual({ - dogCount: -32, - }); - }); - - it("handles a negative float", () => { - expect( - parseEnv({ dogCount: "-329.3" }, { dogCount: z.number() }), - ).toStrictEqual({ - dogCount: -329.3, - }); - }); - - it("throws on a number schema given a string with leading dash", () => { - expect(() => - parseEnv({ dogCount: "-323hello3.3942" }, { dogCount: z.number() }), - ).toThrow(); - }); - - it("handles an object", () => { - const animal = z.object({ - sound: z.string(), - size: z.enum(["big", "medium", "small"]), - }); - - const x = parseEnv( - { - ANIMALS: - '{ "dog": { "sound": "woof", "size": "big" }, "cat": { "sound": "meow", "size": "small" } }', - }, - { - ANIMALS: z.object({ - dog: animal, - cat: animal, - }), - }, - ); - - expect(x).toStrictEqual({ - ANIMALS: { - dog: { - sound: "woof", - size: "big", - }, - cat: { - sound: "meow", - size: "small", - }, - }, - }); - }); - - it("handles an intersection type", () => { - const animal = z.object({ - sound: z.string(), - size: z.enum(["big", "medium", "small"]), - }); - - const thingWithSmell = z.object({ - smell: z.enum(["bad", "very bad"]), - }); - - const x = parseEnv( - { pet: '{ "sound": "woof", "size": "big", "smell": "bad" }' }, - { pet: z.intersection(animal, thingWithSmell) }, - ); - - expect(x).toStrictEqual({ - pet: { sound: "woof", size: "big", smell: "bad" }, - }); - - expect(() => - parseEnv( - { pet: '{ "sound": "woof", "size": "big" }' }, - { pet: z.intersection(animal, thingWithSmell) }, - ), - ).toThrow(); - }); - - it("validates and throws on invalid env values", () => { - // TODO: use more specific throw matcher - expect(() => - parseEnv( - { - HOST: "localhost", - PORT: "50505050", - }, - { - HOST: z.string(), - PORT: port(), - }, - ), - ).toThrow(); - }); - - it("doesn't pass through any env values not in the schema", () => { - const x = parseEnv( - { HOST: "localhost", PORT: "5050" }, - { HOST: z.string() }, - ); - - expect(x).toStrictEqual({ - HOST: "localhost", - }); - }); - - it("handles a detailed spec with defaults", () => { - const x = parseEnv( - { - HOST: "localhost", - }, - { - HOST: z.string(), - PORT: { - schema: port(), - defaults: { _: 4040 }, - }, - }, - ); - - expect(x).toStrictEqual({ - HOST: "localhost", - PORT: 4040, - }); - }); - - it("validates defaults against the schema and throws", () => { - // TODO: use more specific throw matcher - expect(() => - parseEnv( - { - HOST: "localhost", - }, - { - HOST: z.string(), - PORT: { - schema: port(), - defaults: { _: 70000 }, - }, - }, - ), - ).toThrow(); - }); - - it("handles a simple schema with a .transform postprocessor", () => { - const x = parseEnv( - { - FUN_LEVEL: "8", - }, - { - FUN_LEVEL: z - .number() - .int() - .transform((n) => String(n + 10)), - }, - ); - - // @ts-expect-error (2322) -- shouldn't be assignable to number - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const fun: number = x.FUN_LEVEL; - - expect(x.FUN_LEVEL).toBe("18"); - }); - - it("handles a spec with a .transform postprocessor and defaults", () => { - const x = parseEnv( - {}, - { - FUN_LEVEL: { - schema: z - .number() - .int() - .transform((n) => String(n + 10)), - defaults: { _: 8 }, - }, - }, - ); - - const funLevel: string = x.FUN_LEVEL; - - // @ts-expect-error (2322) -- shouldn't be assignable to number - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const funLevelAsNumber: number = x.FUN_LEVEL; - - expect(funLevel).toBe("18"); - }); - - it("throws on a .transform postprocessor with invalid default type", () => { - expect(() => - parseEnv( - {}, - { - FUN_LEVEL: { - schema: z - .number() - .int() - .transform((n) => String(n)), - defaults: { - // @ts-expect-error (2322) -- should be number - _: new Map(), - }, - // @ts-expect-error (2322) -- excess properties should be checked - nonsense: "oops", - }, - }, - ), - ).toThrow(); - }); - - const schemasWithDefaults: [z.ZodType, any][] = [ - [z.number(), 5], - [z.object({ a: z.string(), b: z.bigint() }), { a: "ok", b: 4n }], - ]; - - it("handles spec-provided defaults for common schema types", () => { - expect.hasAssertions(); - - for (const [schema, defaultValue] of schemasWithDefaults) { - const { SOME_SCHEMA } = parseEnv( - {}, - { SOME_SCHEMA: { schema, defaults: { _: defaultValue } } as any }, - ); - - expect(SOME_SCHEMA).toStrictEqual(defaultValue); - } - }); - - it("handles validator-provided defaults for common schema types", () => { - expect.hasAssertions(); - - for (const [schema, defaultValue] of schemasWithDefaults) { - const { SOME_SCHEMA } = parseEnv( - {}, - { SOME_SCHEMA: schema.default(defaultValue) }, - ); - - expect(SOME_SCHEMA).toStrictEqual(defaultValue); - } - }); - - it("handles undefined in production and the given default otherwise", () => { - const schema = { - COOL: { - schema: z.string().nonempty(), - defaults: { - production: undefined, - _: "verycool", - }, - }, - }; - - const expected = { COOL: "verycool" }; - - expect(parseEnv({}, schema)).toStrictEqual(expected); - expect(parseEnv({ NODE_ENV: "dev" }, schema)).toStrictEqual(expected); - expect(() => parseEnv({ NODE_ENV: "production" }, schema)).toThrow(); - }); - - it("throws when a value is passed to z.undefined()", () => { - expect(() => - parseEnv({ DEPRECATED: "something" }, { DEPRECATED: z.undefined() }), - ).toThrow(); - - expect(() => - parseEnv({ DEPRECATED: "" }, { DEPRECATED: z.undefined() }), - ).toThrow(); - - expect(() => parseEnv({}, { DEPRECATED: z.undefined() })).not.toThrow(); - }); - - it("handles a schema with refinement type", () => { - const schema = { - AT_LEAST_FIVE_WORDS: z.string().refine((s) => s.split(" ").length >= 5), - }; - - expect(() => - parseEnv({ AT_LEAST_FIVE_WORDS: "only four words here" }, schema), - ).toThrow(); - - expect(() => - parseEnv({ AT_LEAST_FIVE_WORDS: "but there's five words here!" }, schema), - ).not.toThrow(); - }); - - it("handles a schema that transforms the result", () => { - const res = parseEnv( - { - wordList: "hello there friends", - }, - { - wordList: z.string().transform((s) => s.split(" ")), - }, - ); - - expect(res).toStrictEqual({ wordList: ["hello", "there", "friends"] }); - }); -}); diff --git a/src/v4/parse-env.ts b/src/v4/parse-env.ts deleted file mode 100644 index 6a72fd1..0000000 --- a/src/v4/parse-env.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { $ZodTypes, $ZodType, $ZodDefault } from "zod/v4/core"; -import { getSchemaWithPreprocessor } from "./preprocessors.js"; -import { ErrorWithContext, Reporter, TokenFormatters } from "../reporter.js"; -import { resolveDefaultValueForSpec } from "../shared/utils.js"; -import type * as z from "zod/v4"; -import type { DeepReadonlyObject } from "../util/type-helpers.js"; - -export type SimpleSchema = $ZodType; - -export type DetailedSpec = - TSchema extends SimpleSchema - ? { - /** - * The Zod schema that will be used to parse the passed environment value - * (or any provided default). - */ - schema: TSchema; - - /** - * An object that maps `NODE_ENV` values to default values to pass to the - * schema for this var when the var isn't defined in the environment. For - * example, you could specify `{ production: "my.cool.website", - * development: "localhost:9021" }` to use a local hostname in - * development. - * - * A special key for this object is `_`, which means "the default when - * `NODE_ENV` isn't defined or doesn't match any other provided default." - * - * You can also use `.default()` in a Zod schema to provide a default. - * (For example, `z.number().gte(20).default(50)`.) - */ - defaults?: Record; - } - : never; - -export type Schemas = Record; - -type DetailedSpecKeys = keyof DetailedSpec; - -// There's some trickiness with the function parameter where in some -// circumstances excess parameters are allowed, and this strange-looking type -// fixes it. -export type RestrictSchemas = { - [K in keyof T]: T[K] extends SimpleSchema - ? SimpleSchema - : T[K] extends DetailedSpec - ? DetailedSpec & - Omit, DetailedSpecKeys> - : never; -}; - -export type ParsedSchema = T extends any - ? { - [K in keyof T]: T[K] extends SimpleSchema - ? TOut - : T[K] extends DetailedSpec - ? T[K]["schema"] extends SimpleSchema - ? TOut - : never - : never; - } - : never; - -export type ParseEnv = >( - env: Record, - schemas: T, - reporterOrTokenFormatters?: Reporter | TokenFormatters, -) => DeepReadonlyObject>; - -const handleDeprecatedFields = (schema: z.ZodType) => { - const meta = schema.meta(); - if (meta?.deprecated) { - // TODO: Output deprecation hint - } -}; - -/** - * Parses the passed environment object using the provided map of Zod schemas - * and returns the immutably-typed, parsed environment. - * - * This version of `parseEnv` is intended for internal use and requires a - * reporter or token formatters to be passed in. The versions exported in - * `index.js` and `compat.js` provide defaults for this third parameter, making - * it optional. - */ -export function parseEnvImpl>( - env: Record, - schemas: T, -): DeepReadonlyObject> { - const parsed: Record = {} as DeepReadonlyObject< - ParsedSchema - >; - - const errors: ErrorWithContext[] = []; - - for (const [key, schemaOrSpec] of Object.entries(schemas)) { - const envValue = env[key]; - - let defaultUsed = false; - let defaultValue: unknown; - try { - if (schemaOrSpec instanceof $ZodType) { - if (envValue == null && schemaOrSpec instanceof $ZodDefault) { - defaultUsed = true; - const spec = schemaOrSpec._zod; - defaultValue = spec.def.defaultValue; - // we "unwrap" the default value ourselves and pass it to the schema. - // in the very unlikely case that the value isn't stable AND - // validation fails, this ensures the default value we report is the - // one that was actually used. - // (consider `z.number().gte(0.5).default(() => Math.random())` -- if - // we invoked the default getter and got 0.7, and then ran the parser - // against a missing env var and it generated another default of 0.4, - // we'd report a default value that _should_ have passed.) - parsed[key] = (spec.def.innerType as z.ZodType).parse(defaultValue); - } else { - handleDeprecatedFields(schemaOrSpec as z.ZodType); - parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec as $ZodTypes, - ).parse(envValue, {}); - } - } else if (envValue == null) { - handleDeprecatedFields(schemaOrSpec.schema as z.ZodType); - [defaultUsed, defaultValue] = resolveDefaultValueForSpec( - schemaOrSpec.defaults, - env["NODE_ENV"], - ); - - if (defaultUsed) { - parsed[key] = (schemaOrSpec.schema as z.ZodType).parse(defaultValue); - } else { - // if there's no default, pass our envValue through the - // schema-with-preprocessor (it's an edge case, but our schema might - // accept `null`, and the preprocessor will convert `undefined` to - // `null` for us). - parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec.schema as $ZodTypes, - ).parse(envValue, {}); - } - } else { - parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec.schema as $ZodTypes, - ).parse(envValue, {}); - } - } catch (e) { - errors.push({ - key, - receivedValue: envValue, - error: e, - defaultUsed, - defaultValue, - }); - } - } - - if (errors.length > 0) { - // TODO: Implement reporting... - throw new Error(`Got errors`); - } - - return parsed as DeepReadonlyObject>; -} diff --git a/src/v4/preprocessors.ts b/src/v4/preprocessors.ts deleted file mode 100644 index 5b72be2..0000000 --- a/src/v4/preprocessors.ts +++ /dev/null @@ -1,139 +0,0 @@ -import * as z from "zod/v4"; -import { assertNever } from "../util/type-helpers.js"; -import { - bigInt, - boolean, - date, - identity, - json, - nullProcessor, - number, - throwIfUnknown, - throwIfCurrentlyUnsupported, - throwIfWillNeverBeSupported, -} from "../shared/processing.js"; -import type * as zCore from "zod/v4/core"; - -/** - * Given a Zod schema, returns a function that tries to convert a string (or - * undefined!) to a valid input type for the schema. - */ -export function getPreprocessorByZodType( - schema: zCore.$ZodTypes, -): (arg: string | undefined) => unknown { - const { def } = schema._zod; - - switch (def.type) { - case "pipe": - return getPreprocessorByZodType(def.in as zCore.$ZodTypes); - case "string": - case "enum": - case "undefined": - return identity; - - case "number": - return number; - - case "bigint": - return bigInt; - - case "boolean": - return boolean; - - case "array": - case "object": - case "tuple": - case "record": - case "intersection": - return json; - - case "default": - return getPreprocessorByZodType(def.innerType as zCore.$ZodTypes); - - case "optional": { - const { innerType } = def; - const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); - return (arg) => { - if (arg === undefined) return arg; - return pp(arg); - }; - } - - case "nullable": { - const { innerType } = def; - const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); - return (arg) => { - // coerce undefined to null. - if (arg == null) return null; - return pp(arg); - }; - } - - case "date": - return date; - - case "literal": - switch (typeof def.values?.[0]) { - case "number": - return getPreprocessorByZodType({ - _zod: { def: { type: "number" } }, - } as zCore.$ZodTypes); - case "string": - return getPreprocessorByZodType({ - _zod: { def: { type: "string" } }, - } as zCore.$ZodTypes); - case "boolean": - return getPreprocessorByZodType({ - _zod: { def: { type: "boolean" } }, - } as zCore.$ZodTypes); - default: - return (arg) => arg; - } - - case "null": - return nullProcessor; - - case "union": - return throwIfCurrentlyUnsupported(def.type); - - case "any": - case "unknown": - return throwIfUnknown(def.type); - - // some of these types could maybe be supported (if only via the identity - // function), but don't necessarily represent something meaningful as a - // top-level schema passed to znv. - case "success": - case "catch": - case "nan": - case "template_literal": - case "custom": - case "nonoptional": - case "prefault": - case "transform": - case "file": - case "void": - case "never": - case "lazy": - case "promise": - case "map": - case "set": - case "symbol": - case "readonly": - return throwIfWillNeverBeSupported(def.type); - default: { - assertNever(def); - } - } -} - -/** - * Given a Zod schema, return the schema wrapped in a preprocessor that tries to - * convert a string to the schema's input type. - */ -export function getSchemaWithPreprocessor(schema: zCore.$ZodTypes) { - return z.preprocess( - getPreprocessorByZodType(schema) as (arg: unknown) => unknown, - schema, - ); -} From 51b52c04be9a3f11d4a1432be93e2db2a0fd68f1 Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Thu, 17 Jul 2025 23:40:08 +0200 Subject: [PATCH 4/9] fix: removed wrong exports / imports --- src/index.ts | 2 +- src/parse-env.ts | 2 +- src/preprocessors.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index b77a48e..d9d39e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { z } from "zod/v3"; +export { z } from "zod"; export * from "./parse-env.js"; export * from "./preprocessors.js"; export * from "./extra-schemas.js"; diff --git a/src/parse-env.ts b/src/parse-env.ts index 76a8166..8d9bc9f 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -8,7 +8,7 @@ import { errorMap, } from "./reporter.js"; import { resolveDefaultValueForSpec } from "./shared/utils.js"; -import type * as z from "zod/v4"; +import type * as z from "zod"; import type { DeepReadonlyObject } from "./util/type-helpers.js"; export type SimpleSchema = $ZodType; diff --git a/src/preprocessors.ts b/src/preprocessors.ts index 35ed7cd..5fb828d 100644 --- a/src/preprocessors.ts +++ b/src/preprocessors.ts @@ -1,4 +1,4 @@ -import * as z from "zod/v4"; +import * as z from "zod"; import { assertNever } from "./util/type-helpers.js"; import { bigInt, From e27a21f3c5c3dde0ca49ba4ad0f7779c2ec84f7b Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Thu, 17 Jul 2025 23:44:56 +0200 Subject: [PATCH 5/9] chore: reorganized files --- src/parse-env.ts | 26 +++++++++++++++++++++++++- src/preprocessors.ts | 2 +- src/shared/utils.ts | 16 ---------------- src/{shared => util}/processing.ts | 0 4 files changed, 26 insertions(+), 18 deletions(-) delete mode 100644 src/shared/utils.ts rename src/{shared => util}/processing.ts (100%) diff --git a/src/parse-env.ts b/src/parse-env.ts index 8d9bc9f..a1b2b94 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -7,7 +7,6 @@ import { TokenFormatters, errorMap, } from "./reporter.js"; -import { resolveDefaultValueForSpec } from "./shared/utils.js"; import type * as z from "zod"; import type { DeepReadonlyObject } from "./util/type-helpers.js"; @@ -74,6 +73,31 @@ export type ParsedSchema = T extends any } : never; +/** + * Since there might be a provided default value of `null` or `undefined`, we + * return a tuple that also indicates whether we found a default. + */ +export function resolveDefaultValueForSpec( + defaults: Record | undefined, + nodeEnv: string | undefined, +): [hasDefault: boolean, defaultValue: TIn | undefined] { + if (defaults) { + if (nodeEnv != null && Object.hasOwn(defaults, nodeEnv)) { + return [true, defaults[nodeEnv]]; + } + if ("_" in defaults) return [true, defaults["_"]]; + } + return [false, undefined]; +} + +/** + * Mostly an internal convenience function for testing. Returns the input + * parameter unchanged, but with the same inference used in `parseEnv` applied. + */ +export const inferSchemas = >( + schemas: T, +): T & RestrictSchemas => schemas; + export type ParseEnv = >( env: Record, schemas: T, diff --git a/src/preprocessors.ts b/src/preprocessors.ts index 5fb828d..669788a 100644 --- a/src/preprocessors.ts +++ b/src/preprocessors.ts @@ -11,7 +11,7 @@ import { throwIfUnknown, throwIfCurrentlyUnsupported, throwIfWillNeverBeSupported, -} from "./shared/processing.js"; +} from "./util/processing.js"; import type * as zCore from "zod/v4/core"; /** diff --git a/src/shared/utils.ts b/src/shared/utils.ts deleted file mode 100644 index babe428..0000000 --- a/src/shared/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Since there might be a provided default value of `null` or `undefined`, we - * return a tuple that also indicates whether we found a default. - */ -export function resolveDefaultValueForSpec( - defaults: Record | undefined, - nodeEnv: string | undefined, -): [hasDefault: boolean, defaultValue: TIn | undefined] { - if (defaults) { - if (nodeEnv != null && Object.hasOwn(defaults, nodeEnv)) { - return [true, defaults[nodeEnv]]; - } - if ("_" in defaults) return [true, defaults["_"]]; - } - return [false, undefined]; -} diff --git a/src/shared/processing.ts b/src/util/processing.ts similarity index 100% rename from src/shared/processing.ts rename to src/util/processing.ts From 6e19f721a9cab7445105e563afbc265ba434ce27 Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Thu, 17 Jul 2025 23:52:12 +0200 Subject: [PATCH 6/9] chore: adjusted dependencies --- package.json | 6 +- pnpm-lock.yaml | 146 ++++++++++++++++++++++++------------------------- 2 files changed, 76 insertions(+), 76 deletions(-) diff --git a/package.json b/package.json index a146486..31ade7d 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "prepublishOnly": "run-s -l test build" }, "peerDependencies": { - "zod": "^4.0.0" + "zod": "^4" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", @@ -78,8 +78,8 @@ "prettier": "^3.5.3", "ts-jest": "^29.2.6", "ts-node": "^10.9.2", - "typescript": "^5.8.2", - "zod": "^4" + "typescript": "^5.8.3", + "zod": "~4.0.5" }, "jest": { "preset": "ts-jest/presets/default-esm", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4fc012..919b197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,10 +22,10 @@ importers: version: 9.23.0 eslint-config-lostfictions: specifier: ^7.0.0 - version: 7.0.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0)(is-bun-module@1.3.0)(typescript@5.8.2) + version: 7.0.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0)(is-bun-module@1.3.0)(typescript@5.8.3) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + version: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -34,15 +34,15 @@ importers: version: 3.5.3 ts-jest: specifier: ^29.2.6 - version: 29.2.6(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)))(typescript@5.8.2) + version: 29.2.6(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)))(typescript@5.8.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.13.11)(typescript@5.8.2) + version: 10.9.2(@types/node@22.13.11)(typescript@5.8.3) typescript: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.8.3 + version: 5.8.3 zod: - specifier: ^4 + specifier: ~4.0.5 version: 4.0.5 packages: @@ -2520,8 +2520,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.8.2: - resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true @@ -2972,7 +2972,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2))': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -2986,7 +2986,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -3257,32 +3257,32 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.27.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0)(typescript@5.8.2)': + '@typescript-eslint/eslint-plugin@8.27.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.2) + '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.27.0 - '@typescript-eslint/type-utils': 8.27.0(eslint@9.23.0)(typescript@5.8.2) - '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.2) + '@typescript-eslint/type-utils': 8.27.0(eslint@9.23.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.27.0 eslint: 9.23.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.8.2) - typescript: 5.8.2 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2)': + '@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.27.0 '@typescript-eslint/types': 8.27.0 - '@typescript-eslint/typescript-estree': 8.27.0(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 8.27.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.27.0 debug: 4.4.0 eslint: 9.23.0 - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -3291,20 +3291,20 @@ snapshots: '@typescript-eslint/types': 8.27.0 '@typescript-eslint/visitor-keys': 8.27.0 - '@typescript-eslint/type-utils@8.27.0(eslint@9.23.0)(typescript@5.8.2)': + '@typescript-eslint/type-utils@8.27.0(eslint@9.23.0)(typescript@5.8.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.27.0(typescript@5.8.2) - '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 8.27.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.3) debug: 4.4.0 eslint: 9.23.0 - ts-api-utils: 2.1.0(typescript@5.8.2) - typescript: 5.8.2 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.27.0': {} - '@typescript-eslint/typescript-estree@8.27.0(typescript@5.8.2)': + '@typescript-eslint/typescript-estree@8.27.0(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.27.0 '@typescript-eslint/visitor-keys': 8.27.0 @@ -3313,19 +3313,19 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.1 - ts-api-utils: 2.1.0(typescript@5.8.2) - typescript: 5.8.2 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.2)': + '@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.5.1(eslint@9.23.0) '@typescript-eslint/scope-manager': 8.27.0 '@typescript-eslint/types': 8.27.0 - '@typescript-eslint/typescript-estree': 8.27.0(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 8.27.0(typescript@5.8.3) eslint: 9.23.0 - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -3369,12 +3369,12 @@ snapshots: '@unrs/rspack-resolver-binding-win32-x64-msvc@1.2.2': optional: true - '@vitest/eslint-plugin@1.1.38(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0)(typescript@5.8.2)': + '@vitest/eslint-plugin@1.1.38(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0)(typescript@5.8.3)': dependencies: - '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.2) + '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.3) eslint: 9.23.0 optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 acorn-jsx@5.3.2(acorn@8.14.1): dependencies: @@ -3697,13 +3697,13 @@ snapshots: dependencies: browserslist: 4.24.4 - create-jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)): + create-jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -3922,25 +3922,25 @@ snapshots: eslint: 9.23.0 semver: 7.7.1 - eslint-config-lostfictions@7.0.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0)(is-bun-module@1.3.0)(typescript@5.8.2): + eslint-config-lostfictions@7.0.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0)(is-bun-module@1.3.0)(typescript@5.8.3): dependencies: '@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.23.0) '@eslint/eslintrc': 3.3.1 '@eslint/js': 9.23.0 '@eslint/json': 0.9.1 - '@vitest/eslint-plugin': 1.1.38(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0)(typescript@5.8.2) + '@vitest/eslint-plugin': 1.1.38(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0)(typescript@5.8.3) eslint: 9.23.0 eslint-config-prettier: 10.1.1(eslint@9.23.0) - eslint-import-resolver-typescript: 4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0) + eslint-import-resolver-typescript: 4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0) eslint-plugin-n: 17.16.2(eslint@9.23.0) eslint-plugin-react: 7.37.4(eslint@9.23.0) eslint-plugin-react-hooks: 5.2.0(eslint@9.23.0) eslint-plugin-sonarjs: 3.0.2(eslint@9.23.0) eslint-plugin-unicorn: 56.0.1(eslint@9.23.0) globals: 16.0.0 - typescript: 5.8.2 - typescript-eslint: 8.27.0(eslint@9.23.0)(typescript@5.8.2) + typescript: 5.8.3 + typescript-eslint: 8.27.0(eslint@9.23.0)(typescript@5.8.3) transitivePeerDependencies: - '@typescript-eslint/parser' - '@typescript-eslint/utils' @@ -3962,7 +3962,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0): + eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0): dependencies: debug: 4.4.0 eslint: 9.23.0 @@ -3971,19 +3971,19 @@ snapshots: stable-hash: 0.0.5 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0) is-bun-module: 1.3.0 transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.2) + '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.3) eslint: 9.23.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0) + eslint-import-resolver-typescript: 4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0) transitivePeerDependencies: - supports-color @@ -3994,7 +3994,7 @@ snapshots: eslint: 9.23.0 eslint-compat-utils: 0.5.1(eslint@9.23.0) - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -4005,7 +4005,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.23.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.2.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0))(eslint@9.23.0)(is-bun-module@1.3.0))(eslint@9.23.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4017,7 +4017,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.2) + '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -4072,7 +4072,7 @@ snapshots: minimatch: 9.0.5 scslre: 0.3.0 semver: 7.7.1 - typescript: 5.8.2 + typescript: 5.8.3 eslint-plugin-unicorn@56.0.1(eslint@9.23.0): dependencies: @@ -4614,16 +4614,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)): + jest-cli@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + create-jest: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -4633,7 +4633,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)): + jest-config@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)): dependencies: '@babel/core': 7.26.10 '@jest/test-sequencer': 29.7.0 @@ -4659,7 +4659,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.13.11 - ts-node: 10.9.2(@types/node@22.13.11)(typescript@5.8.2) + ts-node: 10.9.2(@types/node@22.13.11)(typescript@5.8.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -4879,12 +4879,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)): + jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + jest-cli: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5605,22 +5605,22 @@ snapshots: dependencies: is-number: 7.0.0 - ts-api-utils@2.1.0(typescript@5.8.2): + ts-api-utils@2.1.0(typescript@5.8.3): dependencies: - typescript: 5.8.2 + typescript: 5.8.3 - ts-jest@29.2.6(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)))(typescript@5.8.2): + ts-jest@29.2.6(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) + jest: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3)) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.7.1 - typescript: 5.8.2 + typescript: 5.8.3 yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.26.10 @@ -5628,7 +5628,7 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.26.10) - ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2): + ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -5642,7 +5642,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.8.2 + typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 @@ -5701,19 +5701,19 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.27.0(eslint@9.23.0)(typescript@5.8.2): + typescript-eslint@8.27.0(eslint@9.23.0)(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.27.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0)(typescript@5.8.2) - '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.2) - '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.2) + '@typescript-eslint/eslint-plugin': 8.27.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.3))(eslint@9.23.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.27.0(eslint@9.23.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.27.0(eslint@9.23.0)(typescript@5.8.3) eslint: 9.23.0 - typescript: 5.8.2 + typescript: 5.8.3 transitivePeerDependencies: - supports-color typescript@5.6.1-rc: {} - typescript@5.8.2: {} + typescript@5.8.3: {} unbox-primitive@1.1.0: dependencies: From 84d69db3e911773584bfbb828dc6e4010d39d250 Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Fri, 18 Jul 2025 00:00:22 +0200 Subject: [PATCH 7/9] chore: removed error map --- src/parse-env.ts | 11 ++++------- src/reporter.ts | 14 +------------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/parse-env.ts b/src/parse-env.ts index a1b2b94..13cdfbd 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -5,7 +5,6 @@ import { makeDefaultReporter, Reporter, TokenFormatters, - errorMap, } from "./reporter.js"; import type * as z from "zod"; import type { DeepReadonlyObject } from "./util/type-helpers.js"; @@ -147,13 +146,11 @@ export function parseEnvImpl>( // we invoked the default getter and got 0.7, and then ran the parser // against a missing env var and it generated another default of 0.4, // we'd report a default value that _should_ have passed.) - parsed[key] = (spec.def.innerType as z.ZodType).parse(defaultValue, { - error: errorMap, - }); + parsed[key] = (spec.def.innerType as z.ZodType).parse(defaultValue); } else { parsed[key] = getSchemaWithPreprocessor( schemaOrSpec as $ZodTypes, - ).parse(envValue, { error: errorMap }); + ).parse(envValue); } } else if (envValue == null) { [defaultUsed, defaultValue] = resolveDefaultValueForSpec( @@ -170,12 +167,12 @@ export function parseEnvImpl>( // `null` for us). parsed[key] = getSchemaWithPreprocessor( schemaOrSpec.schema as $ZodTypes, - ).parse(envValue, { error: errorMap }); + ).parse(envValue); } } else { parsed[key] = getSchemaWithPreprocessor( schemaOrSpec.schema as $ZodTypes, - ).parse(envValue, { error: errorMap }); + ).parse(envValue); } } catch (e) { errors.push({ diff --git a/src/reporter.ts b/src/reporter.ts index 08e4d4c..122d124 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -1,19 +1,7 @@ -import { $ZodError, $ZodRawIssue, $ZodErrorMap, toDotPath } from "zod/v4/core"; +import { $ZodError, toDotPath } from "zod/v4/core"; import type { Schemas } from "./parse-env.js"; import type * as z from "zod/v4"; -// Even though we also have our own formatter, we pass a custom error map to -// Zod's `.parse()` for two reasons: -// - to ensure that no other consumer of zod in the codebase has set a default -// error map that might override our formatting -// - to return slightly friendlier error messages in some common scenarios. -export const errorMap: $ZodErrorMap = (issue: $ZodRawIssue) => { - if (issue.code === "invalid_type" && issue.input === "undefined") { - return "This field is required."; - } - return undefined; -}; - export interface ErrorWithContext { /** The env var name. */ key: string; From 725877335d6da8a2b48ed703f69e89c855287b24 Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Sat, 8 Nov 2025 11:11:30 +0100 Subject: [PATCH 8/9] fix: avoid some castings; moved exception handlers to inline --- src/parse-env.ts | 18 ++++++++---------- src/preprocessors.ts | 41 ++++++++++++++++++++++++----------------- src/util/processing.ts | 18 ------------------ 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/src/parse-env.ts b/src/parse-env.ts index 13cdfbd..15069eb 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -1,4 +1,4 @@ -import { $ZodTypes, $ZodType, $ZodDefault } from "zod/v4/core"; +import { $ZodType, $ZodDefault } from "zod/v4/core"; import { getSchemaWithPreprocessor } from "./preprocessors.js"; import { ErrorWithContext, @@ -148,9 +148,7 @@ export function parseEnvImpl>( // we'd report a default value that _should_ have passed.) parsed[key] = (spec.def.innerType as z.ZodType).parse(defaultValue); } else { - parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec as $ZodTypes, - ).parse(envValue); + parsed[key] = getSchemaWithPreprocessor(schemaOrSpec).parse(envValue); } } else if (envValue == null) { [defaultUsed, defaultValue] = resolveDefaultValueForSpec( @@ -165,14 +163,14 @@ export function parseEnvImpl>( // schema-with-preprocessor (it's an edge case, but our schema might // accept `null`, and the preprocessor will convert `undefined` to // `null` for us). - parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec.schema as $ZodTypes, - ).parse(envValue); + parsed[key] = getSchemaWithPreprocessor(schemaOrSpec.schema).parse( + envValue, + ); } } else { - parsed[key] = getSchemaWithPreprocessor( - schemaOrSpec.schema as $ZodTypes, - ).parse(envValue); + parsed[key] = getSchemaWithPreprocessor(schemaOrSpec.schema).parse( + envValue, + ); } } catch (e) { errors.push({ diff --git a/src/preprocessors.ts b/src/preprocessors.ts index 669788a..eddb23a 100644 --- a/src/preprocessors.ts +++ b/src/preprocessors.ts @@ -8,9 +8,6 @@ import { json, nullProcessor, number, - throwIfUnknown, - throwIfCurrentlyUnsupported, - throwIfWillNeverBeSupported, } from "./util/processing.js"; import type * as zCore from "zod/v4/core"; @@ -19,13 +16,16 @@ import type * as zCore from "zod/v4/core"; * undefined!) to a valid input type for the schema. */ export function getPreprocessorByZodType( - schema: zCore.$ZodTypes, + _schema: zCore.$ZodType, ): (arg: string | undefined) => unknown { - const { def } = schema._zod; + const schema = _schema as zCore.$ZodTypes; + const { + _zod: { def }, + } = schema; switch (def.type) { case "pipe": - return getPreprocessorByZodType(def.in as zCore.$ZodTypes); + return getPreprocessorByZodType(def.in); case "string": case "enum": case "undefined": @@ -48,11 +48,11 @@ export function getPreprocessorByZodType( return json; case "default": - return getPreprocessorByZodType(def.innerType as zCore.$ZodTypes); + return getPreprocessorByZodType(def.innerType); case "optional": { const { innerType } = def; - const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); + const pp = getPreprocessorByZodType(innerType); return (arg) => { if (arg === undefined) return arg; return pp(arg); @@ -61,7 +61,7 @@ export function getPreprocessorByZodType( case "nullable": { const { innerType } = def; - const pp = getPreprocessorByZodType(innerType as zCore.$ZodTypes); + const pp = getPreprocessorByZodType(innerType); return (arg) => { // coerce undefined to null. if (arg == null) return null; @@ -94,11 +94,19 @@ export function getPreprocessorByZodType( return nullProcessor; case "union": - return throwIfCurrentlyUnsupported(def.type); + throw new Error( + `Zod type not yet supported: "${def.type}" (PRs welcome)`, + ); case "any": case "unknown": - return throwIfUnknown(def.type); + throw new Error( + [ + `Zod type not supported: ${def.type}`, + "You can use `z.string()` or `z.string().optional()` instead of the above type.", + "(Environment variables are already constrained to `string | undefined`.)", + ].join("\n"), + ); // some of these types could maybe be supported (if only via the identity // function), but don't necessarily represent something meaningful as a @@ -120,7 +128,9 @@ export function getPreprocessorByZodType( case "set": case "symbol": case "readonly": - return throwIfWillNeverBeSupported(def.type); + throw new Error( + `Zod type not yet supported: "${def.type}" (PRs welcome)`, + ); default: { assertNever(def); } @@ -131,9 +141,6 @@ export function getPreprocessorByZodType( * Given a Zod schema, return the schema wrapped in a preprocessor that tries to * convert a string to the schema's input type. */ -export function getSchemaWithPreprocessor(schema: zCore.$ZodTypes) { - return z.preprocess( - getPreprocessorByZodType(schema) as (arg: unknown) => unknown, - schema, - ); +export function getSchemaWithPreprocessor(schema: zCore.$ZodType) { + return z.preprocess(getPreprocessorByZodType(schema), schema); } diff --git a/src/util/processing.ts b/src/util/processing.ts index 77a3efb..cd8d900 100644 --- a/src/util/processing.ts +++ b/src/util/processing.ts @@ -78,21 +78,3 @@ export const nullProcessor = (arg: Input) => { if (arg == null) return null; return arg; }; - -export const throwIfUnknown = (typeName: string) => { - throw new Error( - [ - `Zod type not supported: ${typeName}`, - "You can use `z.string()` or `z.string().optional()` instead of the above type.", - "(Environment variables are already constrained to `string | undefined`.)", - ].join("\n"), - ); -}; - -export const throwIfCurrentlyUnsupported = (typeName: string) => { - throw new Error(`Zod type not yet supported: "${typeName}" (PRs welcome)`); -}; - -export const throwIfWillNeverBeSupported = (typeName: string) => { - throw new Error(`Zod type not supported: ${typeName}`); -}; From 3575c945bba5e4b6b4ad435431fd75c81e507f4f Mon Sep 17 00:00:00 2001 From: David Heidrich Date: Sat, 8 Nov 2025 12:03:54 +0100 Subject: [PATCH 9/9] fix: add direct support for `deprecation` through meta, adjusted types, bumped zod --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/__snapshots__/reporter.test.ts.snap | 8 ++++++++ src/parse-env.ts | 22 ++++++++++++++++++++-- src/preprocessors.ts | 13 ++++--------- src/reporter.test.ts | 6 ++++-- 6 files changed, 42 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 31ade7d..b39016e 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "ts-jest": "^29.2.6", "ts-node": "^10.9.2", "typescript": "^5.8.3", - "zod": "~4.0.5" + "zod": "~4.1.12" }, "jest": { "preset": "ts-jest/presets/default-esm", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 919b197..acfde16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 zod: - specifier: ~4.0.5 - version: 4.0.5 + specifier: ~4.1.12 + version: 4.1.12 packages: @@ -2633,8 +2633,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@4.0.5: - resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} snapshots: @@ -5851,4 +5851,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@4.0.5: {} + zod@4.1.12: {} diff --git a/src/__snapshots__/reporter.test.ts.snap b/src/__snapshots__/reporter.test.ts.snap index 1621703..66bf095 100644 --- a/src/__snapshots__/reporter.test.ts.snap +++ b/src/__snapshots__/reporter.test.ts.snap @@ -15,6 +15,14 @@ exports[`reporter should test the error output 1`] = ` ✖ Invalid input: expected number, received string (received "bar") +[DO_NOT_USE_LEGACY]: + ✖ This var is deprecated. + (received "this value shall not exist") + +[DO_NOT_USE_THIS_KEY]: + ✖ This var is deprecated. + (received "this value shall not exist") + [SOME_WITH_DEPRECATED_DESCRIPTION]: Some description ✖ Invalid input: expected string, received undefined (received undefined) diff --git a/src/parse-env.ts b/src/parse-env.ts index 15069eb..bb71701 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -1,4 +1,4 @@ -import { $ZodType, $ZodDefault } from "zod/v4/core"; +import { $ZodType, $ZodDefault, $ZodError } from "zod/v4/core"; import { getSchemaWithPreprocessor } from "./preprocessors.js"; import { ErrorWithContext, @@ -103,6 +103,20 @@ export type ParseEnv = >( reporterOrTokenFormatters?: Reporter | TokenFormatters, ) => DeepReadonlyObject>; +const handleDeprecation = (type: $ZodType) => { + if ((type as z.ZodType).meta()?.deprecated) { + throw new $ZodError([ + { + code: "invalid_type", + message: "This var is deprecated.", + input: type, + path: [], + expected: "undefined", + }, + ]); + } +}; + /** * Parses the passed environment object using the provided map of Zod schemas * and returns the immutably-typed, parsed environment. @@ -133,11 +147,16 @@ export function parseEnvImpl>( let defaultUsed = false; let defaultValue: unknown; try { + handleDeprecation( + "schema" in schemaOrSpec ? schemaOrSpec.schema : schemaOrSpec, + ); + if (schemaOrSpec instanceof $ZodType) { if (envValue == null && schemaOrSpec instanceof $ZodDefault) { defaultUsed = true; const spec = schemaOrSpec._zod; defaultValue = spec.def.defaultValue; + // we "unwrap" the default value ourselves and pass it to the schema. // in the very unlikely case that the value isn't stable AND // validation fails, this ensures the default value we report is the @@ -155,7 +174,6 @@ export function parseEnvImpl>( schemaOrSpec.defaults, env["NODE_ENV"], ); - if (defaultUsed) { parsed[key] = (schemaOrSpec.schema as z.ZodType).parse(defaultValue); } else { diff --git a/src/preprocessors.ts b/src/preprocessors.ts index eddb23a..f2d59c4 100644 --- a/src/preprocessors.ts +++ b/src/preprocessors.ts @@ -75,17 +75,11 @@ export function getPreprocessorByZodType( case "literal": switch (typeof def.values?.[0]) { case "number": - return getPreprocessorByZodType({ - _zod: { def: { type: "number" } }, - } as zCore.$ZodTypes); + return getPreprocessorByZodType(z.number()); case "string": - return getPreprocessorByZodType({ - _zod: { def: { type: "string" } }, - } as zCore.$ZodTypes); + return getPreprocessorByZodType(z.string()); case "boolean": - return getPreprocessorByZodType({ - _zod: { def: { type: "boolean" } }, - } as zCore.$ZodTypes); + return getPreprocessorByZodType(z.boolean()); default: return (arg) => arg; } @@ -127,6 +121,7 @@ export function getPreprocessorByZodType( case "map": case "set": case "symbol": + case "function": case "readonly": throw new Error( `Zod type not yet supported: "${def.type}" (PRs welcome)`, diff --git a/src/reporter.test.ts b/src/reporter.test.ts index 1d3cb13..f4231e0 100644 --- a/src/reporter.test.ts +++ b/src/reporter.test.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { parseEnv, port } from "./compat.js"; +import { deprecate, parseEnv, port } from "./compat.js"; describe("reporter", () => { it("should test the error output", () => { @@ -8,7 +8,8 @@ describe("reporter", () => { { FOO: "bar", CONFIG_OBJECT: JSON.stringify({ a: 123, c: { d: 21 } }), - DO_NOT_USE_THIS_KEY: "this value might not exist", + DO_NOT_USE_THIS_KEY: "this value shall not exist", + DO_NOT_USE_LEGACY: "this value shall not exist", }, { CONFIG_OBJECT: z @@ -25,6 +26,7 @@ describe("reporter", () => { }), BLA: z.string().default("bar"), FOO: z.number(), + DO_NOT_USE_LEGACY: deprecate(), DO_NOT_USE_THIS_KEY: z.string().meta({ deprecated: true, }),