Skip to content

Commit

Permalink
Enhanced typings with optional environment variables
Browse files Browse the repository at this point in the history
  • Loading branch information
jsamr committed Nov 13, 2022
1 parent 26032ce commit 1443777
Show file tree
Hide file tree
Showing 10 changed files with 604 additions and 186 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
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`).
- `makeMarkupValidator` for input which can produce arbitrary output types (e.g. `json`).

Note that `makeValidator` is an alias for `makeBaseValidator` which should cover most of use-cases.

## 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
31 changes: 15 additions & 16 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>>
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
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,11 +88,11 @@ 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
// (fixes #138) Need to figure out why explicitly undefined default/devDefault breaks inference
cleanedEnv[k] = undefined as never
throw new EnvMissingError(formatSpecDescription(spec))
} else {
cleanedEnv[k] = validateVar({ name: k as string, spec, rawValue })
cleanedEnv[k] = validateVar({ name: k as string, spec, rawValue }) as never
}
} catch (err) {
if (options?.reporter === null) throw err
Expand All @@ -103,5 +102,5 @@ export function getSanitizedEnv<T>(

const reporter = options?.reporter || defaultReporter
reporter({ errors, env: cleanedEnv })
return cleanedEnv
return cleanedEnv as FromSpecsRecord<S>
}
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

0 comments on commit 1443777

Please sign in to comment.