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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,3 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm test
- run: pnpm attw . --pack
79 changes: 77 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ optionally provide defaults (which can be matched against `NODE_ENV` values like
`production` or `development`), as well as help strings that will be included in
the error thrown when an env var is missing.

## Features

- No dependencies
- Fully type-safe
- Compatible with serverless environments (import `znv/compat` instead of `znv`)

## Status

Unstable: znv has not yet hit v1.0.0, and per semver there may be breaking
Expand All @@ -31,7 +37,7 @@ about final API design are welcome.
- [Quickstart](#quickstart)
- [Motivation](#motivation)
- [Usage](#usage)
- [`parseEnv`](#parseenvenvironment-schemas)
- [`parseEnv`](#parseenvenvironment-schemas-reporterOrFormatters)
- [Extra schemas](#extra-schemas)
- [Coercion rules](#coercion-rules)
- [Comparison to other libraries](#comparison-to-other-libraries)
Expand All @@ -43,6 +49,8 @@ about final API design are welcome.
```bash
npm i znv zod
# or
pnpm add znv zod
# or
yarn add znv zod
```

Expand Down Expand Up @@ -200,7 +208,7 @@ environments is not straightforward.

## Usage

### `parseEnv(environment, schemas)`
### `parseEnv(environment, schemas, reporterOrFormatters?)`

Parse the given `environment` using the given `schemas`. Returns a read-only
object that maps the keys of the `schemas` object to their respective parsed
Expand All @@ -209,6 +217,13 @@ values.
Throws if any schema fails to parse its respective env var. The error aggregates
all parsing failures for the schemas.

Optionally, you can pass a custom error reporter as the third parameter to
`parseEnv` to customize how errors are displayed. The reporter is a function
that receives error details and returns a `string`. Alternately, you can pass an
object of _token formatters_ as the third parameter to `parseEnv`; this can be
useful if you want to retain the default error reporting format but want to
customize some aspects of it (for example, by redacting secrets).

#### `environment: Record<string, string | undefined>`

You usually want to pass in `process.env` as the first argument.
Expand Down Expand Up @@ -308,6 +323,66 @@ pass a `DetailedSpec` object that has the following fields:
`NODE_ENV: z.enum(["production", "development", "test", "ci"])` to enforce
that `NODE_ENV` is always defined and is one of those four expected values.

#### `reporterOrFormatters?: Reporter | TokenFormatters`

An optional error reporter or object of error token formatters, for customizing
the displayed output when a validation error occurs.

- `Reporter: (errors: ErrorWithContext[], schemas: Schemas) => string`

A reporter is a function that takes a list of errors and the schemas you
passed to `parseEnv` and returns a `string`. Each error has the following
format:

```ts
{
/** The env var name. */
key: string;
/** The actual value present in `process.env[key]`, or undefined. */
receivedValue: unknown;
/** `ZodError` if Zod parsing failed, or `Error` if a preprocessor threw. */
error: unknown;
/** If a default was provided, whether the default value was used. */
defaultUsed: boolean;
/** If a default was provided, the given default value. */
defaultValue: unknown;
}
```

- `TokenFormatters`

An object with the following structure:

```ts
{
/** Formatter for the env var name. */
formatVarName?: (key: string) => string;

/** For parsed objects with errors, formatter for object keys. */
formatObjKey?: (key: string) => string;

/** Formatter for the actual value we received for the env var. */
formatReceivedValue?: (val: unknown) => string;

/** Formatter for the default value provided for the schema. */
formatDefaultValue?: (val: unknown) => string;

/** Formatter for the error summary header. */
formatHeader?: (header: string) => string;
}
```

For example, if you want to redact value names, you can invoke `parseEnv` like
this:

```ts
export const { SOME_VAL } = parseEnv(
process.env,
{ SOME_VAL: z.number().nonnegative() },
{ formatReceivedValue: () => "<redacted>" },
);
```

### Extra schemas

znv exports a very small number of extra schemas for common env var types.
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default defineConfig([
rules: {
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-base-to-string": "off",
},
},
]);
28 changes: 18 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@
"name": "znv",
"version": "0.4.0",
"description": "Parse your environment with Zod schemas",
"type": "module",
"license": "MIT",
"keywords": [
"env",
"process.env",
"zod",
"validation"
],
"main": "dist-cjs/index.js",
"types": "dist/index.d.ts",
"module": "dist/index.js",
"author": "s <https://github.com/lostfictions>",
"homepage": "https://github.com/lostfictions/znv",
"repository": {
Expand All @@ -21,6 +18,10 @@
"bugs": {
"url": "https://github.com/lostfictions/znv/issues"
},
"type": "module",
"main": "dist-cjs/index.js",
"types": "dist/index.d.ts",
"module": "dist/index.js",
"files": [
"dist/",
"dist-cjs/"
Expand All @@ -35,9 +36,18 @@
"types": "./dist-cjs/index.d.ts",
"default": "./dist-cjs/index.js"
}
},
"./compat": {
"import": {
"types": "./dist/compat.d.ts",
"default": "./dist/compat.js"
},
"require": {
"types": "./dist-cjs/compat.d.ts",
"default": "./dist-cjs/compat.js"
}
}
},
"license": "MIT",
"scripts": {
"build": "run-s -l \"build:*\"",
"build:clean": "rm -rf dist/ dist-cjs/",
Expand All @@ -49,13 +59,11 @@
"test:eslint": "eslint --color src",
"test:jest": "jest --colors",
"test:prettier": "prettier -l \"src/**/*\"",
"test:attw": "attw . --pack --profile node16",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we do not need to support node10 anymore. Otherwise we would need to create a dedicated type file and add it to the root, for the compat export.

"prettier": "prettier \"src/**/*\" --write",
"jest": "jest --colors --watch",
"prepublishOnly": "run-s -l test build"
},
"dependencies": {
"colorette": "^2.0.19"
},
"peerDependencies": {
"zod": "^3.24.2"
},
Expand All @@ -64,9 +72,9 @@
"@types/node": "^22.13.10",
"eslint": "^9.22.0",
"eslint-config-lostfictions": "^7.0.0-beta.0",
"jest": "^29.6.4",
"jest": "^29.7.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
"prettier": "^3.5.3",
"ts-jest": "^29.2.6",
"ts-node": "^10.9.2",
"typescript": "^5.8.2",
Expand Down
13 changes: 2 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions src/compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export { z } from "zod";
export * from "./parse-env.js";
export * from "./preprocessors.js";
export * from "./extra-schemas.js";

export type {
DeepReadonly,
DeepReadonlyArray,
DeepReadonlyObject,
} from "./util/type-helpers.js";

import { parseEnvImpl, type ParseEnv } from "./parse-env.js";

/**
* Parses the passed environment object using the provided map of Zod schemas
* and returns the immutably-typed, parsed environment. Compatible with
* serverless and browser environments.
*/
export const parseEnv: ParseEnv = (
env,
schemas,
reporterOrTokenFormatters = {},
) => parseEnvImpl(env, schemas, reporterOrTokenFormatters);
2 changes: 1 addition & 1 deletion src/extra-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseEnv } from "./parse-env.js";
import { parseEnv } from "./index.js";
import { deprecate } from "./extra-schemas.js";

describe("extra schemas", () => {
Expand Down
25 changes: 24 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,31 @@ export { z } from "zod";
export * from "./parse-env.js";
export * from "./preprocessors.js";
export * from "./extra-schemas.js";

export type {
DeepReadonly,
DeepReadonlyArray,
DeepReadonlyObject,
} from "./util.js";
} from "./util/type-helpers.js";

import { parseEnvImpl, type ParseEnv } from "./parse-env.js";
import { cyan, green, red, yellow } from "./util/tty-colors.js";

// This entrypoint provides a colorized reporter by default; this requires tty
// detection, which in turn relies on Node's built-in `tty` module.

/**
* Parses the passed environment object using the provided map of Zod schemas
* and returns the immutably-typed, parsed environment.
*/
export const parseEnv: ParseEnv = (
env,
schemas,
reporterOrTokenFormatters = {
formatVarName: yellow,
formatObjKey: green,
formatReceivedValue: cyan,
formatDefaultValue: cyan,
formatHeader: red,
},
) => parseEnvImpl(env, schemas, reporterOrTokenFormatters);
2 changes: 1 addition & 1 deletion src/parse-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as z from "zod";

import { parseEnv } from "./parse-env.js";
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
Expand Down
33 changes: 28 additions & 5 deletions src/parse-env.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import * as z from "zod";

import { getSchemaWithPreprocessor } from "./preprocessors.js";
import { ErrorWithContext, reportErrors, errorMap } from "./reporter.js";
import {
makeDefaultReporter,
errorMap,
type TokenFormatters,
type ErrorWithContext,
type Reporter,
} from "./reporter.js";

import type { DeepReadonlyObject } from "./util.js";
import type { DeepReadonlyObject } from "./util/type-helpers.js";

export type SimpleSchema<TOut = any, TIn = any> = z.ZodType<
TOut,
Expand Down Expand Up @@ -98,14 +104,31 @@ export const inferSchemas = <T extends Schemas & RestrictSchemas<T>>(
schemas: T,
): T & RestrictSchemas<T> => schemas;

export type ParseEnv = <T extends Schemas & RestrictSchemas<T>>(
env: Record<string, string | undefined>,
schemas: T,
reporterOrTokenFormatters?: Reporter | TokenFormatters,
) => DeepReadonlyObject<ParsedSchema<T>>;

/**
* Parses the passed environment object using the provided map of Zod schemas
* and returns the immutably-typed, parsed environment..
* 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 parseEnv<T extends Schemas & RestrictSchemas<T>>(
export function parseEnvImpl<T extends Schemas & RestrictSchemas<T>>(
env: Record<string, string | undefined>,
schemas: T,
reporterOrTokenFormatters: Reporter | TokenFormatters,
): DeepReadonlyObject<ParsedSchema<T>> {
const reporter =
typeof reporterOrTokenFormatters === "function"
? reporterOrTokenFormatters
: makeDefaultReporter(reporterOrTokenFormatters);

const parsed: Record<string, unknown> = {} as DeepReadonlyObject<
ParsedSchema<T>
>;
Expand Down Expand Up @@ -173,7 +196,7 @@ export function parseEnv<T extends Schemas & RestrictSchemas<T>>(
}

if (errors.length > 0) {
throw new Error(reportErrors(errors, schemas));
throw new Error(reporter(errors, schemas));
}

return parsed as DeepReadonlyObject<ParsedSchema<T>>;
Expand Down
2 changes: 1 addition & 1 deletion src/preprocessors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as z from "zod";

import { assertNever } from "./util.js";
import { assertNever } from "./util/type-helpers.js";

const { ZodFirstPartyTypeKind: TypeName } = z;

Expand Down
Loading