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
66 changes: 66 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 @@ -149,7 +151,71 @@ const env = cleanEnv(process.env, {
INITIALS: twochars()
});
```
### TypeScript users

You can use either one of `makeValidator`, `makeExactValidator` and `makeStructuredValidator`
depending on your use case.

#### `makeValidator<BaseT>`

This validator has the output narrowed-down to a subtype of `BaseT` (e.g. `str`).
Example of a custom integer validator:

```ts
const int = makeValidator<number>((input: string) => {
const coerced = parseInt(input, 10)
if (Number.isNaN(coerced)) throw new EnvError(`Invalid integer input: "${input}"`)
return coerced
})
const MAX_RETRIES = int({ choices: [1, 2, 3, 4] })
// Narrows down output type to '1 | 2 | 3 | 4' witch is a subtype of 'number'
```

#### `makeExactValidator<T>`

This validator has the output widened to `T` (e.g. `bool`). To understand the difference
with `makeValidator`, let's use it in the same scenario:

```ts
const int = makeExactValidator<number>((input: string) => {
const coerced = parseInt(input, 10)
if (Number.isNaN(coerced)) throw new EnvError(`Invalid integer input: "${input}"`)
return coerced
})
const MAX_RETRIES = int({ choices: [1, 2, 3, 4] })
// Output type is 'number'
```

As you can see in this instance, _the output type is exactly `number`, the parameter type of
`makeExactValidator`_. Also note that here, `int` is not parametrizable.

#### `makeStructuredValidator`
jsamr marked this conversation as resolved.
Show resolved Hide resolved

This validator is meant for inputs which can produce arbitrary output types (e.g. `json`).
The typing logic behaves differently here:

- `makeStructuredValidator` has no type parameter.
- When no types can be inferred from context, output type defaults to any.
- Otherwise, infers type from `default` or `devDefault`.
- Also allows validator parametrized types.
- Finally, the generated validator disallow `choices` parameter.

Below is an example of a validator for query parameters (e.g. `option1=foo&option2=bar`)

```ts
const queryParams = makeStructuredValidator((input: string) => {
const params = new URLSearchParams(input)
return Object.fromEntries(params.entries())
})
const OPTIONS1 = queryParams()
// Output type 'any'
const OPTIONS2 = queryParams({ default: { option1: 'foo', option2: 'bar' } })
// Output type '{ option1: string, option2: string }'
const OPTIONS3 = queryParams<{ option1?: string; option2?: string }>({
default: { option1: 'foo', option2: 'bar' },
})
// Output type '{ option1?: string, option2?: string }'
```

## 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
24 changes: 11 additions & 13 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, SpecsOutput, 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<SpecsOutput<S>> = {},
): SpecsOutput<S> {
let cleanedEnv = {} as SpecsOutput<S>
const castedSpecs = specs as unknown as Record<keyof S, ValidatorSpec<unknown>>
const errors = {} as Record<keyof S, Error>
const varKeys = Object.keys(castedSpecs) as Array<keyof S>
const rawNodeEnv = readRawEnvValue(environment, 'NODE_ENV')

for (const k of varKeys) {
const spec = specs[k]
const spec = castedSpecs[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 Down
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, SpecsOutput } 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: SpecsOutput<S>, rawEnv: unknown) => MW,
options: CleanOptions<S> = {},
): Readonly<MW> {
const cleaned = getSanitizedEnv(environment, specs, options)
return Object.freeze(applyMiddleware(cleaned, environment))
Expand Down
117 changes: 94 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,105 @@ 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 type OptionalSpec<T> = Omit<Spec<T>, 'default'> & { default: undefined }
export type OptionalTypelessSpec = Omit<OptionalSpec<unknown>, 'choices'>
jsamr marked this conversation as resolved.
Show resolved Hide resolved

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 ChoicelessOptionalSpec<T> = Omit<Spec<T>, 'default' | 'choices'> & {
default: undefined
}

export interface ValidatorSpec<T> extends Spec<T> {
export type ChoicelessRequiredSpec<T> =
| (Omit<Spec<T>, 'choices'> & { default: NonNullable<T> })
| Omit<Spec<T>, 'default' | 'choices'>

export type ChoicelessRequiredSpecWithType<T> = ChoicelessRequiredSpec<T> &
(
| {
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.
export interface BaseValidator<BaseT> {
// These function overloads enable nuanced type inferences for optimal DX
// This will prevent specifying "default" alone from narrowing down output type.
// https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads
(spec: ChoicelessRequiredSpecWithType<BaseT>): RequiredValidatorSpec<BaseT>
<T extends BaseT>(spec?: RequiredSpec<T>): RequiredValidatorSpec<T>
<T extends BaseT>(spec: OptionalSpec<T>): OptionalValidatorSpec<T>
}

// Such validator inputs a structured input format 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.
// - One can't pass "choices" since choices uses reference equality.
export interface StructuredValidator {
// 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: OptionalTypelessSpec): OptionalValidatorSpec<any>
<T>(spec: ChoicelessOptionalSpec<T>): OptionalValidatorSpec<T>
<T>(spec: ChoicelessRequiredSpec<T>): RequiredValidatorSpec<T>
}

export type SpecsOutput<S> = {
[K in keyof S]: unknown
}

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