Skip to content

Commit

Permalink
Enhanced typings with optional environment variables (#194)
Browse files Browse the repository at this point in the history
  • Loading branch information
jsamr authored Nov 28, 2022
1 parent 26032ce commit e06dc88
Show file tree
Hide file tree
Showing 12 changed files with 619 additions and 188 deletions.
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
38 changes: 38 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,43 @@ 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.

## 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
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
22 changes: 11 additions & 11 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 } 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,14 +29,14 @@ 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: CleanedEnv<S>, rawEnv: unknown) => MW,
options: CleanOptions<S> = {},
): Readonly<MW> {
const cleaned = getSanitizedEnv(environment, specs, options)
return Object.freeze(applyMiddleware(cleaned, environment))
return Object.freeze(applyMiddleware(cleaned as CleanedEnv<S>, environment))
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './middleware'
export * from './types'
export * from './validators'
export * from './reporter'
export { makeExactValidator, makeValidator } from './makers'
77 changes: 77 additions & 0 deletions src/makers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Spec, BaseValidator, StructuredValidator, ExactValidator } from './types'

const internalMakeValidator = <T>(parseFn: (input: string) => T) => {
return (spec?: Spec<unknown>) => ({ ...spec, _parse: parseFn })
}

/**
* Creates a validator which can output subtypes of `BaseT`. E.g.:
*
* ```ts
* const int = makeValidator<number>((input: string) => {
* // Implementation details
* })
* const MAX_RETRIES = int({ choices: [1, 2, 3, 4] })
* // Narrows down output type to 1 | 2 | 3 | 4
* ```
*
* @param parseFn - A function to parse and validate input.
* @returns A validator which output type is narrowed-down to a subtype of `BaseT`
*/
export const makeValidator = <BaseT>(parseFn: (input: string) => BaseT): BaseValidator<BaseT> => {
return internalMakeValidator(parseFn) as BaseValidator<BaseT>
}

/**
* Creates a validator which output type is exactly T:
*
* ```ts
* const int = makeExactValidator<number>((input: string) => {
* // Implementation details
* })
* const MAX_RETRIES = int({ choices: [1, 2, 3, 4] })
* // Output type 'number'
* ```
*
* @param parseFn - A function to parse and validate input.
* @returns A validator which output type is exactly `T`
*/
export const makeExactValidator = <T>(parseFn: (input: string) => T): ExactValidator<T> => {
return internalMakeValidator(parseFn) as ExactValidator<T>
}

/**
* This validator is meant for inputs which can produce arbitrary output types (e.g. json).
* The typing logic behaves differently from other makers:
*
* - 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 generated validators have an output type parameter.
* - Finally, the generated validators 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 }'
* ```
*
* @param parseFn - A function to parse and validate input.
* @returns A validator which output type is exactly `T`
*/
export const makeStructuredValidator = (
parseFn: (input: string) => unknown,
): StructuredValidator => {
return internalMakeValidator(parseFn) as StructuredValidator
}
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
}

type OptionalSpec<T> = Omit<Spec<T>, 'default'> & { default: undefined }
type OptionalTypelessSpec = Omit<OptionalSpec<unknown>, 'choices'>

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

type ChoicelessOptionalSpec<T> = Omit<Spec<T>, 'default' | 'choices'> & {
default: undefined
}

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

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

0 comments on commit e06dc88

Please sign in to comment.