Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add edge runtime compatibility and allow custom formatters #15

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
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
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
37 changes: 22 additions & 15 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 @@ -53,22 +63,19 @@
"jest": "jest --colors --watch",
"prepublishOnly": "run-s -l test build"
},
"dependencies": {
"colorette": "^2.0.19"
},
"peerDependencies": {
"zod": "^3.13.2"
},
"devDependencies": {
"@types/jest": "^29.5.4",
"@types/node": "^16.18.24",
"eslint": "^8.48.0",
"eslint-config-lostfictions": "^6.0.0",
"jest": "^29.6.4",
"@types/jest": "^29.5.11",
"@types/node": "^18.19.3",
"eslint": "^8.55.0",
"eslint-config-lostfictions": "^6.1.0",
"jest": "^29.7.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
"prettier": "^3.1.1",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"ts-node": "^10.9.2",
"typescript": "^4.9.5",
"zod": "~3.13.2"
},
Expand Down
17 changes: 17 additions & 0 deletions src/compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export { z } from "zod";
export * from "./parse-env.js";
export * from "./preprocessors.js";
export * from "./extra-schemas.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
22 changes: 22 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,25 @@ export { z } from "zod";
export * from "./parse-env.js";
export * from "./preprocessors.js";
export * from "./extra-schemas.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
49 changes: 36 additions & 13 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 @@ -55,20 +61,20 @@ export type RestrictSchemas<T extends Schemas> = {
[K in keyof T]: T[K] extends SimpleSchema
? SimpleSchema
: T[K] extends DetailedSpec
? DetailedSpec<T[K]["schema"]> &
Omit<Record<keyof T[K], never>, DetailedSpecKeys>
: never;
? DetailedSpec<T[K]["schema"]> &
Omit<Record<keyof T[K], never>, DetailedSpecKeys>
: never;
};

export type ParsedSchema<T extends Schemas> = T extends any
? {
[K in keyof T]: T[K] extends SimpleSchema<infer TOut>
? TOut
: T[K] extends DetailedSpec
? T[K]["schema"] extends SimpleSchema<infer TOut>
? TOut
: never
: never;
? T[K]["schema"] extends SimpleSchema<infer TOut>
? TOut
: never
: never;
}
: never;

Expand Down Expand Up @@ -100,14 +106,31 @@ export const inferSchemas = <T extends Schemas>(
schemas: T & RestrictSchemas<T>,
): T & RestrictSchemas<T> => schemas;

export type ParseEnv = <T extends Schemas>(
env: Record<string, string | undefined>,
schemas: T & RestrictSchemas<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>(
export function parseEnvImpl<T extends Schemas>(
env: Record<string, string | undefined>,
schemas: T & RestrictSchemas<T>,
schemas: T,
reporterOrTokenFormatters: Reporter | TokenFormatters,
): DeepReadonlyObject<ParsedSchema<T>> {
const reporter =
typeof reporterOrTokenFormatters === "function"
? reporterOrTokenFormatters
: makeDefaultReporter(reporterOrTokenFormatters);

const parsed: Record<string, unknown> = {} as any;

const errors: ErrorWithContext[] = [];
Expand Down Expand Up @@ -173,7 +196,7 @@ export function parseEnv<T extends Schemas>(
}

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