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

Enhanced typings with optional environment variables #194

Merged
merged 14 commits into from
Nov 28, 2022
Merged
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ node_modules
coverage
npm-debug.log
yarn-error.log
dist
dist
.vscode
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ Each validation function accepts an (optional) object with the following attribu

## Custom validators

### Basic usage

You can easily create your own validator functions with `envalid.makeValidator()`. It takes
a function as its only parameter, and should either return a cleaned value, or throw if the
input is unacceptable:
Expand All @@ -150,6 +152,16 @@ const env = cleanEnv(process.env, {
});
```

### TypeScript users

You can use either one of `makeBaseValidator`, `makeExactValidator` and `makeMarkupValidator`
depending on your use case:

- `makeBaseValidator<BaseT>` when you want the output to be narrowed-down to a subtype of `BaseT` (e.g. `str`).
- `makeExactValidator<T>` when you want the output to be widened to `T` (e.g. `bool`).
Copy link
Owner

Choose a reason for hiding this comment

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

Could probably use examples of each case (makeValidator vs makeExactValidator) here as I don't think it'll be clear to most readers without diving into the source

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let me know if that's enough: 9727612

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Couldn't find the comment about formatting again; but fixed it here: ce1007f

- `makeMarkupValidator` for input which can produce arbitrary output types (e.g. `json`).
Copy link
Owner

Choose a reason for hiding this comment

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

I don't know if it makes sense to expose makeMarkupValidator in the public API. What other use cases can you think of for it? If we keep it private for now we can always expose it later. Given the domain of "parsing env var strings" I think json() would be the dominant case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was thinking query parameters, e.g. "option1=true&option2=false&token=XXX"; or POSIX like flags e.g. -b -c --long-flag=/bin/xxx, /etc/fstab, XML, YAML... JSON5; honestly I don't know much about backend developers practices , but I had imagined exposing this one would give plenty of flexibility for consumers of this library, freeing them from the hassle of typings these validators correctly.

PS1: didn't test but I think we should also forbid specs with choices in markup validators, correct?
PS2: I went back and forth between naming this validator markupValidator and structuredValidator ; English is not my native language and if you have better naming ideas I'm all for it

Copy link
Owner

Choose a reason for hiding this comment

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

I think structuredValidator would be a better name for sure, when most people hear "markup" they probably think of a specific type of format like HTML/XML/etc.

Also re choices, a nice side effect of removing structuredValidator from the public API is we don't have to worry about that interaction :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also re choices, a nice side effect of removing structuredValidator from the public API is we don't have to worry about that interaction :)

Well, with one important exception: json validator. In any case, I did implement this specific logic here:
328e056

Yet, I'm happy to not export makeStructuredValidator if you don't want to.


Note that `makeValidator` is an alias for `makeBaseValidator` which should cover most of use-cases.
jsamr marked this conversation as resolved.
Show resolved Hide resolved

## Error Reporting

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"devDependencies": {
"@types/jest": "28.1.8",
"@types/node": "17.0.21",
"expect-type": "^0.15.0",
"husky": "7.0.4",
"jest": "28.1.3",
"prettier": "2.5.1",
Expand Down
26 changes: 12 additions & 14 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EnvError, EnvMissingError } from './errors'
import { CleanOptions, Spec, ValidatorSpec } from './types'
import { CleanOptions, FromSpecsRecord, Spec, ValidatorSpec } from './types'
import { defaultReporter } from './reporter'

export const testOnlySymbol = Symbol('envalid - test only')
Expand Down Expand Up @@ -51,18 +51,19 @@ const isTestOnlySymbol = (value: any): value is symbol => value === testOnlySymb
/**
* Perform the central validation/sanitization logic on the full environment object
*/
export function getSanitizedEnv<T>(
export function getSanitizedEnv<S>(
environment: unknown,
specs: { [K in keyof T]: ValidatorSpec<T[K]> },
options: CleanOptions<T> = {},
): T {
let cleanedEnv = {} as T
const errors: Partial<Record<keyof T, Error>> = {}
const varKeys = Object.keys(specs) as Array<keyof T>
specs: S,
options: CleanOptions<FromSpecsRecord<S>> = {},
): FromSpecsRecord<S> {
let cleanedEnv = {} as Record<keyof S, unknown>
const castSpecs = specs as unknown as Record<keyof S, ValidatorSpec<unknown>>
jsamr marked this conversation as resolved.
Show resolved Hide resolved
const errors = {} as Record<keyof S, Error>
const varKeys = Object.keys(castSpecs) as Array<keyof S>
const rawNodeEnv = readRawEnvValue(environment, 'NODE_ENV')

for (const k of varKeys) {
const spec = specs[k]
const spec = castSpecs[k]
const rawValue = readRawEnvValue(environment, k)

// If no value was given and default/devDefault were provided, return the appropriate default
Expand All @@ -72,12 +73,10 @@ export function getSanitizedEnv<T>(
const usingDevDefault =
rawNodeEnv && rawNodeEnv !== 'production' && spec.hasOwnProperty('devDefault')
if (usingDevDefault) {
// @ts-expect-error default values can break the rules slightly by being explicitly set to undefined
jsamr marked this conversation as resolved.
Show resolved Hide resolved
cleanedEnv[k] = spec.devDefault
continue
}
if (spec.hasOwnProperty('default')) {
// @ts-expect-error default values can break the rules slightly by being explicitly set to undefined
if ('default' in spec) {
cleanedEnv[k] = spec.default
continue
}
Expand All @@ -89,7 +88,6 @@ export function getSanitizedEnv<T>(
}

if (rawValue === undefined) {
// @ts-ignore (fixes #138) Need to figure out why explicitly undefined default/devDefault breaks inference
cleanedEnv[k] = undefined
throw new EnvMissingError(formatSpecDescription(spec))
} else {
Expand All @@ -103,5 +101,5 @@ export function getSanitizedEnv<T>(

const reporter = options?.reporter || defaultReporter
reporter({ errors, env: cleanedEnv })
return cleanedEnv
return cleanedEnv as FromSpecsRecord<S>
jsamr marked this conversation as resolved.
Show resolved Hide resolved
}
20 changes: 10 additions & 10 deletions src/envalid.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CleanedEnvAccessors, CleanOptions, ValidatorSpec } from './types'
import { CleanedEnv, CleanOptions, FromSpecsRecord } from './types'
import { getSanitizedEnv, testOnlySymbol } from './core'
import { applyDefaultMiddleware } from './middleware'

Expand All @@ -10,13 +10,13 @@ import { applyDefaultMiddleware } from './middleware'
* @param specs An object that specifies the format of required vars.
* @param options An object that specifies options for cleanEnv.
*/
export function cleanEnv<T>(
export function cleanEnv<S>(
environment: unknown,
specs: { [K in keyof T]: ValidatorSpec<T[K]> },
options: CleanOptions<T> = {},
): Readonly<T & CleanedEnvAccessors> {
specs: S,
options: CleanOptions<S> = {},
): CleanedEnv<S> {
const cleaned = getSanitizedEnv(environment, specs, options)
return Object.freeze(applyDefaultMiddleware(cleaned, environment))
return Object.freeze(applyDefaultMiddleware(cleaned, environment)) as CleanedEnv<S>
}

/**
Expand All @@ -29,11 +29,11 @@ export function cleanEnv<T>(
* @param applyMiddleware A function that applies transformations to the cleaned env object
* @param options An object that specifies options for cleanEnv.
*/
export function customCleanEnv<T, MW>(
export function customCleanEnv<S, MW>(
environment: unknown,
specs: { [K in keyof T]: ValidatorSpec<T[K]> },
applyMiddleware: (cleaned: T, rawEnv: unknown) => MW,
options: CleanOptions<T> = {},
specs: S,
applyMiddleware: (cleaned: FromSpecsRecord<S>, rawEnv: unknown) => MW,
options: CleanOptions<S> = {},
): Readonly<MW> {
const cleaned = getSanitizedEnv(environment, specs, options)
return Object.freeze(applyMiddleware(cleaned, environment))
Expand Down
106 changes: 83 additions & 23 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,8 @@
// Hacky conditional type to prevent default/devDefault from narrowing type T to a single value.
// Ideally this could be replaced by something that would enforce the default value being a subset
// of T, without affecting the definition of T itself
type DefaultType<T> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: T extends object
? object
: any

export interface Spec<T> {
/**
* An Array that lists the admissable parsed values for the env var.
*/
choices?: ReadonlyArray<T>
/**
* A fallback value, which will be used if the env var wasn't specified. Providing a default effectively makes the env var optional.
*/
default?: DefaultType<T>
/**
* A fallback value to use only when NODE_ENV is not 'production'.
* This is handy for env vars that are required for production environments, but optional for development and testing.
*/
devDefault?: DefaultType<T>
/**
* A string that describes the env var.
*/
Expand All @@ -37,12 +15,94 @@ export interface Spec<T> {
* A url that leads to more detailed documentation about the env var.
*/
docs?: string
/**
* A fallback value, which will be used if the env var wasn't specified. Providing a default effectively makes the env var optional.
*/
default?: NonNullable<T> | undefined
/**
* A fallback value to use only when NODE_ENV is not 'production'.
* This is handy for env vars that are required for production environments, but optional for development and testing.
*/
devDefault?: NonNullable<T> | undefined
}

export interface ValidatorSpec<T> extends Spec<T> {
export type OptionalSpec<T> = Omit<Spec<T>, 'default'> & { default: undefined }
export type OptionalChoiceless = Omit<OptionalSpec<unknown>, 'choices'>

export type RequiredSpec<T> = (Spec<T> & { default: NonNullable<T> }) | Omit<Spec<T>, 'default'>
export type RequiredTypelessSpec = Omit<Spec<unknown>, 'choices' | 'default'> & {
devDefault?: undefined
}
export type RequiredChoicelessSpecWithType<T> = Omit<Spec<T>, 'choices'> &
(
| {
default: NonNullable<T>
}
| {
devDefault: NonNullable<T>
}
)

type WithParser<T> = {
_parse: (input: string) => T
}

export type RequiredValidatorSpec<T> = RequiredSpec<T> & WithParser<T>

export type OptionalValidatorSpec<T> = OptionalSpec<T> & WithParser<T>

export type ValidatorSpec<T> = RequiredValidatorSpec<T> | OptionalValidatorSpec<T>

// Such validator works for exactly one type. You can't parametrize
// the output type at invocation site (e.g.: boolean).
export interface ExactValidator<T> {
(spec?: RequiredSpec<T>): RequiredValidatorSpec<T>
(spec: OptionalSpec<T>): OptionalValidatorSpec<T>
}

// Such validator only works for subtypes of BaseT (hence, specialized).
export interface SuperValidator<BaseT> {
// These overrides enable nuanced type inferences for optimal DX
// This will prevent specifying "default" alone from narrowing down output type
(spec: RequiredChoicelessSpecWithType<BaseT>): RequiredValidatorSpec<BaseT>
<T extends BaseT>(spec?: RequiredSpec<T>): RequiredValidatorSpec<T>
<T extends BaseT>(spec: OptionalSpec<T>): OptionalValidatorSpec<T>
}

// Such validator inputs a markup language such as JSON.
// Because it can output complex types, including objects:
// - it has no supertype
// - it fallbacks to 'any' when no type information can be inferred
// from the spec object.
export interface MarkupValidator {
// Defaults to any when no argument (prevents 'unknown')
(): RequiredValidatorSpec<any>
// Allow overriding output type with type parameter
<T>(): RequiredValidatorSpec<T>
// Make sure we grab 'any' when no type inference can be made
// otherwise it would resolve to 'unknown'
(spec: RequiredTypelessSpec): RequiredValidatorSpec<any>
(spec: OptionalChoiceless): OptionalValidatorSpec<any>
<T>(spec: OptionalSpec<T>): OptionalValidatorSpec<T>
<T>(spec: RequiredSpec<T>): RequiredValidatorSpec<T>
}

export type FromSpecsRecord<S> = {
[K in keyof S]: S[K] extends ValidatorSpec<infer U> ? U : never
}

export type CleanedEnv<S> = S extends Record<string, ValidatorSpec<unknown>>
? Readonly<
{
[K in keyof S]: S[K] extends OptionalValidatorSpec<infer U>
? U | undefined
: S[K] extends RequiredValidatorSpec<infer U>
? U
: never
} & CleanedEnvAccessors
>
: never

export interface CleanedEnvAccessors {
/** true if NODE_ENV === 'development' */
readonly isDevelopment: boolean
Expand Down
Loading