├── .gitignore ├── mod.ts ├── utils.ts ├── errors_test.ts ├── errors.ts ├── types.ts ├── envalid.ts ├── middleware.ts ├── reporter.ts ├── core.ts ├── README.md ├── middleware_test.ts ├── validators.ts ├── reporter_test.ts └── validators_test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./envalid.ts"; 2 | export * from "./errors.ts"; 3 | export * from "./middleware.ts"; 4 | export * from "./types.ts"; 5 | export * from "./validators.ts"; 6 | export * from "./reporter.ts"; 7 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std@0.153.0/testing/asserts.ts"; 2 | import { cleanEnv, ValidatorSpec } from "./mod.ts"; 3 | 4 | // Ensure that a given environment spec passes through all values from the given 5 | // env object 6 | export const assertPassthrough = ( 7 | env: T, 8 | // deno-lint-ignore no-explicit-any 9 | spec: { [k in keyof T]: ValidatorSpec }, 10 | ) => { 11 | assertEquals(cleanEnv(env, spec), env); 12 | }; 13 | -------------------------------------------------------------------------------- /errors_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std@0.153.0/testing/asserts.ts"; 2 | import { EnvError, EnvMissingError } from "./mod.ts"; 3 | 4 | Deno.test("EnvError", () => { 5 | const e = new EnvError("baz"); 6 | assertEquals(e instanceof EnvError, true); 7 | assertEquals(e instanceof TypeError, true); 8 | assertEquals(e.name, "EnvError"); 9 | }); 10 | 11 | Deno.test("EnvMissingError", () => { 12 | const e = new EnvMissingError("baz"); 13 | assertEquals(e instanceof EnvMissingError, true); 14 | assertEquals(e instanceof ReferenceError, true); 15 | assertEquals(e.name, "EnvMissingError"); 16 | }); 17 | -------------------------------------------------------------------------------- /errors.ts: -------------------------------------------------------------------------------- 1 | // Surprisingly involved error subclassing 2 | // See https://stackoverflow.com/questions/41102060/typescript-extending-error-class 3 | 4 | export class EnvError extends TypeError { 5 | constructor(message?: string) { 6 | super(message); 7 | Object.setPrototypeOf(this, new.target.prototype); 8 | Error.captureStackTrace(this, EnvError); 9 | this.name = this.constructor.name; 10 | } 11 | } 12 | 13 | export class EnvMissingError extends ReferenceError { 14 | constructor(message?: string) { 15 | super(message); 16 | Object.setPrototypeOf(this, new.target.prototype); 17 | Error.captureStackTrace(this, EnvMissingError); 18 | this.name = this.constructor.name; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | type DefaultType = T extends string ? string 2 | : T extends number ? number 3 | : T extends boolean ? boolean 4 | : T extends Record ? Record 5 | : // deno-lint-ignore no-explicit-any 6 | any; 7 | 8 | export interface Spec { 9 | /** 10 | * An array of permitted values 11 | */ 12 | choices?: ReadonlyArray; 13 | /** 14 | * A fallback value to use if the variable was not set. Providing this effectively makes the variable optional. 15 | */ 16 | default?: DefaultType; 17 | /** 18 | * Description of the variable 19 | */ 20 | desc?: string; 21 | /** 22 | * Example value 23 | */ 24 | example?: string; 25 | /** 26 | * Documentation URL 27 | */ 28 | docs?: string; 29 | } 30 | 31 | export interface ValidatorSpec extends Spec { 32 | _parse: (input: string) => T; 33 | } 34 | 35 | export interface ReporterOptions { 36 | errors: Partial>; 37 | env: unknown; 38 | } 39 | 40 | export interface CleanOptions { 41 | /** 42 | * A function that overrides the default reporter 43 | */ 44 | reporter?: ((opts: ReporterOptions) => void) | null; 45 | } 46 | -------------------------------------------------------------------------------- /envalid.ts: -------------------------------------------------------------------------------- 1 | import { CleanOptions, ValidatorSpec } from "./types.ts"; 2 | import { getSanitizedEnv } from "./core.ts"; 3 | import { applyDefaultMiddleware } from "./middleware.ts"; 4 | 5 | /** 6 | * Returns a sanitized, immutable environment object. _Only_ the variables 7 | * specified in the `specs` parameter will be accessible on the returned 8 | * object. 9 | * 10 | * @param environment An object containing the variables, e.g. `Deno.env.toObject()`. 11 | * @param specs The specification to enforce on the environment. 12 | * @param options 13 | */ 14 | export function cleanEnv>( 15 | environment: unknown, 16 | specs: { [K in keyof T]: ValidatorSpec }, 17 | options: CleanOptions = {}, 18 | ): Readonly { 19 | const cleaned = getSanitizedEnv(environment, specs, options); 20 | return Object.freeze(applyDefaultMiddleware(cleaned, environment)); 21 | } 22 | 23 | /** 24 | * Returns a sanitized, immutable environment object, and passes it through a custom 25 | * `applyMiddleware` function. This won't be required in most use cases. 26 | * 27 | * @param environment An object containing the variables, e.g. `Deno.env.toObject()`. 28 | * @param specs The specification to enforce on the environment. 29 | * @param applyMiddleware A function that applies transformations to the cleaned environment. 30 | * @param options 31 | */ 32 | export function customCleanEnv( 33 | environment: unknown, 34 | specs: { [K in keyof T]: ValidatorSpec }, 35 | applyMiddleware: (cleaned: T, rawEnv: unknown) => MW, 36 | options: CleanOptions = {}, 37 | ): Readonly { 38 | const cleaned = getSanitizedEnv(environment, specs, options); 39 | return Object.freeze(applyMiddleware(cleaned, environment)); 40 | } 41 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | export const strictProxyMiddleware = >( 2 | envObj: T, 3 | rawEnv: unknown, 4 | ) => { 5 | const inspectables = [ 6 | "constructor", 7 | "length", 8 | "inspect", 9 | "hasOwnProperty", 10 | "toJSON", // Allow JSON.stringify() on output. See af/envalid#157 11 | Symbol.toStringTag, 12 | Symbol.iterator, 13 | 14 | // For libs that use `then` checks to see if objects are Promises (see af/envalid#74): 15 | "then", 16 | // For usage with TypeScript esModuleInterop flag 17 | "__esModule", 18 | ]; 19 | 20 | return new Proxy(envObj, { 21 | get(target, name: string) { 22 | // These checks are needed because calling console.log on a 23 | // proxy that throws crashes the entire process. This permits access on 24 | // the necessary properties for `console.log(envObj)`, `envObj.length`, 25 | // `Object.prototype.hasOwnProperty.call(envObj, 'string')` to work. 26 | if (inspectables.includes(name)) { 27 | // @ts-expect-error TS doesn't like symbol types as indexers 28 | return target[name]; 29 | } 30 | 31 | const varExists = Object.prototype.hasOwnProperty.call(target, name); 32 | 33 | if (!varExists) { 34 | if ( 35 | typeof rawEnv === "object" && rawEnv && 36 | Object.prototype.hasOwnProperty.call(rawEnv, name) 37 | ) { 38 | throw new ReferenceError( 39 | `[envalid] Env var ${name} was accessed but not validated. This var is set in the environment; please add an envalid validator for it.`, 40 | ); 41 | } 42 | 43 | throw new ReferenceError(`[envalid] Env var not found: ${name}`); 44 | } 45 | 46 | return target[name as keyof T]; 47 | }, 48 | 49 | set(_target, name: string) { 50 | throw new TypeError( 51 | `[envalid] Attempt to mutate environment value: ${name}`, 52 | ); 53 | }, 54 | }); 55 | }; 56 | 57 | export const applyDefaultMiddleware = >( 58 | cleanedEnv: T, 59 | rawEnv: unknown, 60 | ) => { 61 | // Note: Ideally we would declare the default middlewares in an array and apply them in series with 62 | // a generic pipe() function. However, a generically typed variadic pipe() appears to not be possible 63 | // in TypeScript as of 4.x, so we just manually apply them below. See 64 | // https://github.com/microsoft/TypeScript/pull/39094#issuecomment-647042984 65 | return strictProxyMiddleware(cleanedEnv, rawEnv); 66 | }; 67 | -------------------------------------------------------------------------------- /reporter.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { 3 | blue, 4 | white, 5 | yellow, 6 | } from "https://deno.land/std@0.153.0/fmt/colors.ts"; 7 | import { EnvMissingError } from "./errors.ts"; 8 | import { ReporterOptions } from "./types.ts"; 9 | 10 | type Errors = Partial>; 11 | type Logger = (data: any, ...args: any[]) => void; 12 | 13 | // The default reporter is supports a second argument, for consumers 14 | // who want to use it with only small customizations 15 | type ExtraOptions = { 16 | onError?: (errors: Errors) => void; 17 | logger: (output: string) => void; 18 | }; 19 | 20 | const defaultLogger = console.error.bind(console); 21 | 22 | const RULE = white("================================"); 23 | 24 | // Takes the provided errors, formats them all to an output string, and passes that string output to the 25 | // provided "logger" function. 26 | // 27 | // This is exposed in the public API so third-party reporters can leverage this logic if desired 28 | export const envalidErrorFormatter = ( 29 | errors: Errors, 30 | logger: Logger = defaultLogger, 31 | ) => { 32 | const missingVarsOutput: string[] = []; 33 | const invalidVarsOutput: string[] = []; 34 | for (const [k, err] of Object.entries(errors)) { 35 | if (err instanceof EnvMissingError) { 36 | missingVarsOutput.push( 37 | ` ${blue(k)}: ${err.message || "(required)"}`, 38 | ); 39 | } else { 40 | invalidVarsOutput.push( 41 | ` ${blue(k)}: ${(err as Error)?.message || "(invalid format)"}`, 42 | ); 43 | } 44 | } 45 | 46 | // Prepend "header" output for each section of the output: 47 | if (invalidVarsOutput.length) { 48 | invalidVarsOutput.unshift( 49 | ` ${yellow("Invalid")} environment variables:`, 50 | ); 51 | } 52 | if (missingVarsOutput.length) { 53 | missingVarsOutput.unshift( 54 | ` ${yellow("Missing")} environment variables:`, 55 | ); 56 | } 57 | 58 | const output = [ 59 | missingVarsOutput.sort().join("\n"), 60 | RULE, 61 | ] 62 | .filter((x) => !!x) 63 | .join("\n"); 64 | 65 | logger(output); 66 | }; 67 | 68 | export const defaultReporter = ( 69 | { errors = {} }: ReporterOptions, 70 | { onError, logger }: ExtraOptions = { logger: defaultLogger }, 71 | ) => { 72 | if (!Object.keys(errors).length) return; 73 | envalidErrorFormatter(errors, logger); 74 | 75 | if (onError) { 76 | onError(errors); 77 | } else if (!Deno.noColor) { 78 | logger(yellow("\n Exiting with error code 1")); 79 | if (!(Deno.env.get("DENO_DEPLOYMENT_ID") !== undefined)) Deno.exit(1); 80 | } else { 81 | throw new TypeError("Environment validation failed"); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /core.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { EnvError, EnvMissingError } from "./errors.ts"; 3 | import { CleanOptions, Spec, ValidatorSpec } from "./types.ts"; 4 | import { defaultReporter } from "./reporter.ts"; 5 | 6 | export const testOnlySymbol = Symbol("envalid - test only"); 7 | 8 | /** 9 | * Validate a single variable. 10 | * 11 | * @throws {EnvError} if unsuccessful 12 | * @return The cleaned value 13 | */ 14 | function validateVar({ 15 | spec, 16 | name, 17 | rawValue, 18 | }: { 19 | name: string; 20 | rawValue: string | T; 21 | spec: ValidatorSpec; 22 | }) { 23 | if (typeof spec._parse !== "function") { 24 | throw new EnvError(`Invalid spec for "${name}"`); 25 | } 26 | const value = spec._parse(rawValue as string); 27 | 28 | if (spec.choices) { 29 | if (!Array.isArray(spec.choices)) { 30 | throw new TypeError(`"choices" must be an array (in spec for "${name}")`); 31 | } else if (!spec.choices.includes(value)) { 32 | throw new EnvError(`Value "${value}" not in choices [${spec.choices}]`); 33 | } 34 | } 35 | if (value == null) throw new EnvError(`Invalid value for env var "${name}"`); 36 | return value; 37 | } 38 | 39 | /** 40 | * Returns the error message for a missing variable. 41 | */ 42 | function formatSpecDescription(spec: Spec) { 43 | const egText = spec.example ? ` (eg. "${spec.example}")` : ""; 44 | const docsText = spec.docs ? `. See ${spec.docs}` : ""; 45 | return `${spec.desc}${egText}${docsText}`; 46 | } 47 | 48 | const readRawEnvValue = ( 49 | env: unknown, 50 | k: keyof T, 51 | ): string | T[keyof T] => { 52 | return (env as any)[k]; 53 | }; 54 | 55 | const isTestOnlySymbol = (value: any): value is symbol => 56 | value === testOnlySymbol; 57 | 58 | /** 59 | * Perform the central validation/sanitization on the environment object. 60 | */ 61 | export function getSanitizedEnv( 62 | environment: unknown, 63 | specs: { [K in keyof T]: ValidatorSpec }, 64 | options: CleanOptions = {}, 65 | ): T { 66 | const cleanedEnv = {} as T; 67 | const errors: Partial> = {}; 68 | const varKeys = Object.keys(specs) as Array; 69 | 70 | for (const k of varKeys) { 71 | const spec = specs[k]; 72 | const rawValue = readRawEnvValue(environment, k); 73 | 74 | // If no value was given and default was provided, return the appropriate default 75 | // value without passing it through validation 76 | if (rawValue === undefined) { 77 | if (Object.prototype.hasOwnProperty.call(spec, "default")) { 78 | // @ts-expect-error default values can break the rules slightly by being explicitly set to undefined 79 | cleanedEnv[k] = spec.default; 80 | continue; 81 | } 82 | } 83 | 84 | try { 85 | if (isTestOnlySymbol(rawValue)) { 86 | throw new EnvMissingError(formatSpecDescription(spec)); 87 | } 88 | 89 | if (rawValue === undefined) { 90 | // @ts-ignore (fixes af/envalid#138) Need to figure out why explicitly undefined default breaks inference 91 | cleanedEnv[k] = undefined; 92 | throw new EnvMissingError(formatSpecDescription(spec)); 93 | } else { 94 | cleanedEnv[k] = validateVar({ name: k as string, spec, rawValue }); 95 | } 96 | } catch (err) { 97 | if (options?.reporter === null) throw err; 98 | if (err instanceof Error) errors[k] = err; 99 | } 100 | } 101 | 102 | const reporter = options?.reporter || defaultReporter; 103 | reporter({ errors, env: cleanedEnv }); 104 | return cleanedEnv; 105 | } 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # envalid 4 | 5 | Validate environment variables. 6 | 7 | ## Usage 8 | 9 | ```ts 10 | // You can also use it with dotenv: 11 | // import "https://deno.land/std/dotenv/load.ts"; 12 | import { bool, cleanEnv, num, str } from "https://deno.land/x/envalid/mod.ts"; 13 | 14 | // You can also do 15 | // export default cleanEnv( 16 | // to be importable from other locations. 17 | const env = cleanEnv(Deno.env.toObject(), { 18 | TEXT: str(), 19 | IS_X: bool(), 20 | APP_ID: num(), 21 | SOMETHING: str({ 22 | default: "default value", 23 | example: "some string", 24 | desc: "description", 25 | docs: "https://example.com/configuration#SOMETHING", 26 | }), 27 | CHOICE: str({ choices: ["can be this", "or this"] }), 28 | }); 29 | ``` 30 | 31 | ## Validators 32 | 33 | - `url()` - Requires the value to be a URL. 34 | - `email()` - Requires the value to be an email address. 35 | - `num()` - Parses values like "42", "0.23", and "1e5" into numbers. 36 | - `json()` - Requires the value to be JSON, and calls `JSON.parse()` on it. 37 | - `str()` - Requires the value to be set if a default value was not provided. 38 | - `port()` - Requires the value to be a port (1-6553), and converts it to 39 | number. 40 | - `host()` - Requires the value to be either a fully-qualified domain name or a 41 | v4/v6 IP address. 42 | - `bool()` - Requires the value to be one of "1", "0", "true", "false", "t", or 43 | "f", and converts it to boolean. 44 | 45 | ## Custom Validators 46 | 47 | You can create your own validators with `makeValidator()`. It takes a single 48 | parameter which should be a function that returns either the cleaned value, or 49 | throws if the value is not acceptable. 50 | 51 | ```ts 52 | import { cleanEnv, makeValidator } from "https://deno.land/x/envalid/mod.ts"; 53 | 54 | const twoUppercaseLetters = makeValidator((x) => { 55 | if (/^[A-Za-z]{2}$/.test(x)) { 56 | return x.toUpperCase(); 57 | } else { 58 | throw new Error("Expected two letters"); 59 | } 60 | }); 61 | 62 | const env = cleanEnv(Deno.env.toObject(), { 63 | TWO_UPPERCASE_LETTERS: twoUppercaseLetters(), 64 | }); 65 | ``` 66 | 67 | ## Reporting Errors 68 | 69 | By default, if any variable is missing or has an invalid value, an error message 70 | will be displayed and the process exits with 1. You can override this behavior 71 | by using your own reporter: 72 | 73 | ```ts 74 | import { cleanEnv } from "https://deno.land/x/envalid/mod.ts"; 75 | 76 | const report = (error: string) => { 77 | // 78 | }; 79 | 80 | const env = cleanEnv(Deno.env.toObject(), {}, { 81 | reporter: ({ errors, env }) => { 82 | report("Invalid environment variables: " + Object.keys(errors)); 83 | }, 84 | }); 85 | ``` 86 | 87 | The error classes `EnvError` and `EnvMissingError` can also be used to examine 88 | the errors: 89 | 90 | ```ts 91 | import { 92 | cleanEnv, 93 | EnvError, 94 | EnvMissingError, 95 | } from "https://deno.land/x/envalid/mod.ts"; 96 | 97 | const env = cleanEnv(Deno.env.toObject(), {}, { 98 | reporter: ({ errors, env }) => { 99 | for (const [envVar, err] of Object.entries(errors)) { 100 | if (err instanceof EnvError) { 101 | // 102 | } else if (err instanceof EnvMissingError) { 103 | // 104 | } else { 105 | // 106 | } 107 | } 108 | }, 109 | }); 110 | ``` 111 | 112 | ## Custom Middleware (Advanced) 113 | 114 | The `customCleanEnv()` function allows you to completely override the 115 | preprocessing and validations. Its arguments are similar to `cleanEnv()` except 116 | for the third one being the custom middleware function. 117 | 118 | The custom middleware function can modify the variables after they have been 119 | cleaned and validated. The default middleware function, 120 | `applyDefaultMiddleware()` can also be combined with it. 121 | 122 | ## Credits 123 | 124 | - [af/envalid](https://github.com/af/envalid). 125 | -------------------------------------------------------------------------------- /middleware_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assertThrows, 4 | } from "https://deno.land/std@0.153.0/testing/asserts.ts"; 5 | import { cleanEnv, customCleanEnv, str } from "./mod.ts"; 6 | 7 | Deno.test("customCleanEnv middleware type inference", async (t) => { 8 | await t.step("allows access to properties on the output object", () => { 9 | const raw = { FOO: "bar" }; 10 | const cleaned = customCleanEnv(raw, { FOO: str() }, (inputEnv) => ({ 11 | ...inputEnv, 12 | FOO: inputEnv.FOO.toUpperCase(), 13 | ADDED: 123, 14 | })); 15 | 16 | assertEquals(cleaned, { FOO: "BAR", ADDED: 123 }); 17 | }); 18 | 19 | await t.step("flags errors on input env", () => { 20 | const noop = (x: unknown) => x; 21 | const raw = { FOO: "bar" }; 22 | const cleaned = customCleanEnv(raw, { FOO: str() }, (inputEnv) => { 23 | // @ts-expect-error Inference should tell us this property is invalid 24 | noop(inputEnv.WRONG_NAME); 25 | return inputEnv; 26 | }); 27 | 28 | assertEquals(cleaned, raw); 29 | }); 30 | }); 31 | 32 | Deno.test("proxy middleware", async (t) => { 33 | await t.step( 34 | "only specified fields are passed through from validation", 35 | () => { 36 | const env = cleanEnv( 37 | { FOO: "bar", BAZ: "baz" }, 38 | { 39 | FOO: str(), 40 | }, 41 | ); 42 | assertEquals(env, { FOO: "bar" }); 43 | }, 44 | ); 45 | 46 | await t.step("proxy throws when invalid attrs are accessed", () => { 47 | const env = cleanEnv( 48 | { FOO: "bar", BAZ: "baz" }, 49 | { 50 | FOO: str(), 51 | }, 52 | ); 53 | assertEquals(env.FOO, "bar"); 54 | // @ts-expect-error This invalid usage should trigger a type error 55 | assertThrows(() => env.ASDF); 56 | }); 57 | 58 | await t.step("proxy throws when attempting to mutate", () => { 59 | const env = cleanEnv( 60 | { FOO: "bar", BAZ: "baz" }, 61 | { 62 | FOO: str(), 63 | }, 64 | ); 65 | assertThrows( 66 | // @ts-expect-error This invalid usage should trigger a type error 67 | () => (env.FOO = "foooooo"), 68 | "[envalid] Attempt to mutate environment value: FOO", 69 | ); 70 | }); 71 | 72 | await t.step( 73 | "proxy throws and suggests to add a validator if name is in orig env", 74 | () => { 75 | const env = cleanEnv( 76 | { FOO: "foo", BAR: "wat" }, 77 | { 78 | BAR: str(), 79 | }, 80 | ); 81 | 82 | assertThrows( 83 | // @ts-expect-error This invalid usage should trigger a type error 84 | () => env.FOO, 85 | "[envalid] Env var FOO was accessed but not validated. This var is set in the environment; please add an envalid validator for it.", 86 | ); 87 | }, 88 | ); 89 | 90 | await t.step("proxy does not error out on .length checks (#70)", () => { 91 | const env = cleanEnv( 92 | { FOO: "foo" }, 93 | { 94 | FOO: str(), 95 | }, 96 | ); 97 | 98 | // @ts-expect-error This invalid usage should trigger a type error 99 | assertThrows(() => assertThrows(() => env.length)); 100 | }); 101 | 102 | await t.step("proxy allows `then` on self", () => { 103 | const env = cleanEnv( 104 | { FOO: "foo" }, 105 | { 106 | FOO: str(), 107 | }, 108 | ); 109 | 110 | // @ts-expect-error This invalid usage should trigger a type error 111 | assertThrows(() => assertThrows(() => env.then)); 112 | }); 113 | 114 | await t.step("proxy allows `__esModule` on self", () => { 115 | const env = cleanEnv( 116 | { FOO: "foo" }, 117 | { 118 | FOO: str(), 119 | }, 120 | ); 121 | 122 | // @ts-expect-error This invalid usage should trigger a type error 123 | assertThrows(() => assertThrows(() => env.__esModule)); 124 | }); 125 | 126 | await t.step("proxy allows JSON.stringify to be called on output", () => { 127 | const env = cleanEnv( 128 | { FOO: "foo" }, 129 | { 130 | FOO: str(), 131 | }, 132 | ); 133 | 134 | assertThrows(() => assertThrows(() => JSON.stringify(env))); 135 | assertEquals(JSON.stringify(env), '{"FOO":"foo"}'); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /validators.ts: -------------------------------------------------------------------------------- 1 | import { Spec, ValidatorSpec } from "./types.ts"; 2 | import { EnvError } from "./errors.ts"; 3 | 4 | // Simplified adaptation of https://github.com/validatorjs/validator.js/blob/master/src/lib/isFQDN.js 5 | const isFQDN = (input: string) => { 6 | if (!input.length) return false; 7 | const parts = input.split("."); 8 | for (let part, i = 0; i < parts.length; i++) { 9 | part = parts[i]; 10 | if (!/^[a-z\u00a1-\uffff0-9-]+$/i.test(part)) return false; 11 | if (/[\uff01-\uff5e]/.test(part)) return false; // disallow full-width chars 12 | if (part[0] === "-" || part[part.length - 1] === "-") return false; 13 | } 14 | return true; 15 | }; 16 | 17 | // "best effort" regex-based IP address check 18 | // If you want a more exhaustive check, create your own custom validator, perhaps wrapping this 19 | // implementation (the source of the ipv4 regex below): https://github.com/validatorjs/validator.js/blob/master/src/lib/isIP.js 20 | const ipv4Regex = 21 | /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; 22 | const ipv6Regex = /([a-f0-9]+:+)+[a-f0-9]+/; 23 | const isIP = (input: string) => { 24 | if (!input.length) return false; 25 | return ipv4Regex.test(input) || ipv6Regex.test(input); 26 | }; 27 | 28 | const EMAIL_REGEX = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; // intentionally non-exhaustive 29 | 30 | export const makeValidator = (parseFn: (input: string) => T) => { 31 | return function (spec?: Spec): ValidatorSpec { 32 | return { ...spec, _parse: parseFn }; 33 | }; 34 | }; 35 | 36 | // The reason for the function wrapper is to enable the type parameter 37 | // that enables better type inference. For more context, see 38 | // https://github.com/af/envalid/pull/118 39 | export function bool(spec?: Spec) { 40 | return makeValidator((input: string | boolean) => { 41 | switch (input) { 42 | case true: 43 | case "true": 44 | case "t": 45 | case "1": 46 | return true as T; 47 | case false: 48 | case "false": 49 | case "f": 50 | case "0": 51 | return false as T; 52 | default: 53 | throw new EnvError(`Invalid bool input: "${input}"`); 54 | } 55 | })(spec); 56 | } 57 | 58 | export function num(spec?: Spec) { 59 | return makeValidator((input: string) => { 60 | const coerced = parseFloat(input); 61 | if (Number.isNaN(coerced)) { 62 | throw new EnvError(`Invalid number input: "${input}"`); 63 | } 64 | return coerced as T; 65 | })(spec); 66 | } 67 | 68 | export function str(spec?: Spec) { 69 | return makeValidator((input: string) => { 70 | if (typeof input === "string") return input as T; 71 | throw new EnvError(`Not a string: "${input}"`); 72 | })(spec); 73 | } 74 | 75 | export function email(spec?: Spec) { 76 | return makeValidator((x: string) => { 77 | if (EMAIL_REGEX.test(x)) return x as T; 78 | throw new EnvError(`Invalid email address: "${x}"`); 79 | })(spec); 80 | } 81 | 82 | export function host(spec?: Spec) { 83 | return makeValidator((input: string) => { 84 | if (!isFQDN(input) && !isIP(input)) { 85 | throw new EnvError(`Invalid host (domain or ip): "${input}"`); 86 | } 87 | return input as T; 88 | })(spec); 89 | } 90 | 91 | export function port(spec?: Spec) { 92 | return makeValidator((input: string) => { 93 | const coerced = +input; 94 | if ( 95 | Number.isNaN(coerced) || 96 | `${coerced}` !== `${input}` || 97 | coerced % 1 !== 0 || 98 | coerced < 1 || 99 | coerced > 65535 100 | ) { 101 | throw new EnvError(`Invalid port input: "${input}"`); 102 | } 103 | return coerced as T; 104 | })(spec); 105 | } 106 | 107 | export function url(spec?: Spec) { 108 | return makeValidator((x: string) => { 109 | try { 110 | new URL(x); 111 | return x as T; 112 | } catch (_err) { 113 | throw new EnvError(`Invalid url: "${x}"`); 114 | } 115 | })(spec); 116 | } 117 | 118 | /** 119 | * It's recommended that you provide an explicit type parameter for json validation 120 | * if you're using TypeScript. Otherwise, the output will be typed as `any`. 121 | */ 122 | // deno-lint-ignore no-explicit-any 123 | export function json(spec?: Spec) { 124 | return makeValidator((x: string) => { 125 | try { 126 | return JSON.parse(x) as T; 127 | } catch (_err) { 128 | throw new EnvError(`Invalid json: "${x}"`); 129 | } 130 | })(spec); 131 | } 132 | -------------------------------------------------------------------------------- /reporter_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assertMatch, 4 | assertNotMatch, 5 | } from "https://deno.land/std@0.153.0/testing/asserts.ts"; 6 | import { 7 | defaultReporter as mainReporterExport, 8 | envalidErrorFormatter as mainEnvalidErrorFormatter, 9 | } from "./mod.ts"; 10 | import { defaultReporter, envalidErrorFormatter } from "./reporter.ts"; 11 | import { EnvMissingError } from "./errors.ts"; 12 | 13 | Deno.test("default reporter", async (t) => { 14 | await t.step( 15 | "default reporter should be exported from the top-level module", 16 | () => { 17 | assertEquals(mainReporterExport, defaultReporter); 18 | }, 19 | ); 20 | 21 | await t.step( 22 | "simple usage for reporting a missing variable error", 23 | async () => { 24 | const { code, stderr } = await Deno.spawn(Deno.execPath(), { 25 | args: [ 26 | "eval", 27 | ` 28 | import { defaultReporter } from "./reporter.ts"; 29 | import { EnvMissingError } from "./mod.ts"; 30 | defaultReporter( 31 | { 32 | errors: { FOO: new EnvMissingError() }, 33 | env: {}, 34 | }, 35 | ); 36 | `, 37 | ], 38 | }); 39 | 40 | const output = new TextDecoder().decode(stderr); 41 | 42 | assertMatch(output, /Missing\S+ environment variables:/); 43 | assertMatch(output, /FOO\S+/); 44 | assertMatch(output, /\(required\)/); 45 | assertNotMatch(output, /Invalid\S+ environment variables:/); 46 | assertMatch(output, /Exiting with error code 1/); 47 | assertEquals(code, 1); 48 | }, 49 | ); 50 | 51 | await t.step( 52 | "simple usage for reporting an invalid variable error", 53 | async () => { 54 | const { code, stderr } = await Deno.spawn(Deno.execPath(), { 55 | args: [ 56 | "eval", 57 | ` 58 | import { defaultReporter } from "./reporter.ts"; 59 | import { EnvError } from "./errors.ts"; 60 | defaultReporter( 61 | { 62 | errors: { FOO: new EnvError() }, 63 | env: { FOO: 123 }, 64 | }, 65 | ); 66 | `, 67 | ], 68 | }); 69 | 70 | const output = new TextDecoder().decode(stderr); 71 | 72 | assertMatch(output, /Invalid\S+ environment variables:/); 73 | assertMatch(output, /FOO\S+/); 74 | assertMatch(output, /\(invalid format\)/); 75 | assertNotMatch(output, /Missing\S+ environment variables:/); 76 | assertMatch(output, /Exiting with error code 1/); 77 | assertEquals(code, 1); 78 | }, 79 | ); 80 | 81 | await t.step( 82 | "reporting an invalid variable error with a custom error message", 83 | async () => { 84 | const { code, stderr } = await Deno.spawn(Deno.execPath(), { 85 | args: [ 86 | "eval", 87 | ` 88 | import { defaultReporter } from "./reporter.ts"; 89 | import { EnvError } from "./errors.ts"; 90 | defaultReporter( 91 | { 92 | errors: { FOO: new EnvError("custom msg") }, 93 | env: { FOO: 123 }, 94 | }, 95 | ); 96 | `, 97 | ], 98 | }); 99 | 100 | const output = new TextDecoder().decode(stderr); 101 | 102 | assertMatch(output, /Invalid\S+ environment variables:/); 103 | assertMatch(output, /FOO\S+/); 104 | assertMatch(output, /custom msg/); 105 | assertMatch(output, /Exiting with error code 1/); 106 | assertEquals(code, 1); 107 | }, 108 | ); 109 | 110 | await t.step("does nothing when there are no errors", async () => { 111 | const { code, stderr } = await Deno.spawn(Deno.execPath(), { 112 | args: [ 113 | "eval", 114 | ` 115 | import { defaultReporter } from "./reporter.ts"; 116 | defaultReporter( 117 | { 118 | errors: {}, 119 | env: { FOO: "great success" }, 120 | }, 121 | ); 122 | `, 123 | ], 124 | }); 125 | 126 | const output = new TextDecoder().decode(stderr); 127 | 128 | assertEquals(output, ""); 129 | assertEquals(code, 0); 130 | }); 131 | }); 132 | 133 | Deno.test("envalidErrorFormatter", async (t) => { 134 | await t.step( 135 | "default formatter should be exported from the top-level module", 136 | () => { 137 | assertEquals(mainEnvalidErrorFormatter, envalidErrorFormatter); 138 | }, 139 | ); 140 | 141 | await t.step("simple usage for formatting a single error", () => { 142 | const messages = new Array(); 143 | envalidErrorFormatter( 144 | { FOO: new EnvMissingError() }, 145 | (msg: string) => messages.push(msg), 146 | ); 147 | 148 | const output = messages[0]; 149 | 150 | assertEquals(messages.length, 1); 151 | assertMatch(output, /Missing\S+ environment variables:/); 152 | assertMatch(output, /FOO\S+/); 153 | assertMatch(output, /\(required\)/); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /validators_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assertThrows, 4 | } from "https://deno.land/std@0.153.0/testing/asserts.ts"; 5 | import { 6 | bool, 7 | cleanEnv, 8 | email, 9 | host, 10 | json, 11 | makeValidator, 12 | num, 13 | port, 14 | str, 15 | url, 16 | } from "./mod.ts"; 17 | import { assertPassthrough } from "./utils.ts"; 18 | 19 | const makeSilent = { reporter: null }; 20 | 21 | Deno.test("bool()", () => { 22 | assertThrows(() => cleanEnv({ FOO: "asfd" }, { FOO: bool() }, makeSilent)); 23 | 24 | const trueBool = cleanEnv({ FOO: true }, { FOO: bool() }); 25 | assertEquals(trueBool, { FOO: true }); 26 | 27 | const falseBool = cleanEnv({ FOO: false }, { FOO: bool() }); 28 | assertEquals(falseBool, { FOO: false }); 29 | 30 | const truthyNum = cleanEnv({ FOO: "1" }, { FOO: bool() }); 31 | assertEquals(truthyNum, { FOO: true }); 32 | const falsyNum = cleanEnv({ FOO: "0" }, { FOO: bool() }); 33 | assertEquals(falsyNum, { FOO: false }); 34 | 35 | const trueStr = cleanEnv({ FOO: "true" }, { FOO: bool() }); 36 | assertEquals(trueStr, { FOO: true }); 37 | 38 | const falseStr = cleanEnv({ FOO: "false" }, { FOO: bool() }); 39 | assertEquals(falseStr, { FOO: false }); 40 | 41 | const t = cleanEnv({ FOO: "t" }, { FOO: bool() }); 42 | assertEquals(t, { FOO: true }); 43 | const f = cleanEnv({ FOO: "f" }, { FOO: bool() }); 44 | assertEquals(f, { FOO: false }); 45 | 46 | const defaultF = cleanEnv({}, { FOO: bool({ default: false }) }); 47 | assertEquals(defaultF, { FOO: false }); 48 | }); 49 | 50 | Deno.test("num()", () => { 51 | const withInt = cleanEnv({ FOO: "1" }, { FOO: num() }); 52 | assertEquals(withInt, { FOO: 1 }); 53 | 54 | const withFloat = cleanEnv({ FOO: "0.34" }, { FOO: num() }); 55 | assertEquals(withFloat, { FOO: 0.34 }); 56 | 57 | const withExponent = cleanEnv({ FOO: "1e3" }, { FOO: num() }); 58 | assertEquals(withExponent, { FOO: 1000 }); 59 | 60 | const withZero = cleanEnv({ FOO: 0 }, { FOO: num() }); 61 | assertEquals(withZero, { FOO: 0 }); 62 | 63 | assertThrows(() => cleanEnv({ FOO: "asdf" }, { FOO: num() }, makeSilent)); 64 | 65 | assertThrows(() => cleanEnv({ FOO: "" }, { FOO: num() }, makeSilent)); 66 | }); 67 | 68 | Deno.test("email()", () => { 69 | const spec = { FOO: email() }; 70 | assertPassthrough({ FOO: "foo@example.com" }, spec); 71 | assertPassthrough({ FOO: "foo.bar@my.example.com" }, spec); 72 | 73 | assertThrows(() => cleanEnv({ FOO: "asdf@asdf" }, spec, makeSilent)); 74 | assertThrows(() => cleanEnv({ FOO: "1" }, spec, makeSilent)); 75 | }); 76 | 77 | Deno.test("host()", () => { 78 | const spec = { FOO: host() }; 79 | assertPassthrough({ FOO: "example.com" }, spec); 80 | assertPassthrough({ FOO: "localhost" }, spec); 81 | assertPassthrough({ FOO: "192.168.0.1" }, spec); 82 | assertPassthrough({ FOO: "2001:0db8:85a3:0000:0000:8a2e:0370:7334" }, spec); 83 | 84 | assertThrows(() => cleanEnv({ FOO: "" }, spec, makeSilent)); 85 | assertThrows(() => cleanEnv({ FOO: "example.com." }, spec, makeSilent)); 86 | }); 87 | 88 | Deno.test("port()", () => { 89 | const spec = { FOO: port() }; 90 | 91 | const with1 = cleanEnv({ FOO: "1" }, spec); 92 | assertEquals(with1, { FOO: 1 }); 93 | const with80 = cleanEnv({ FOO: "80" }, spec); 94 | assertEquals(with80, { FOO: 80 }); 95 | const with80Num = cleanEnv({ FOO: 80 }, spec); 96 | assertEquals(with80Num, { FOO: 80 }); 97 | const with65535 = cleanEnv({ FOO: "65535" }, spec); 98 | assertEquals(with65535, { FOO: 65535 }); 99 | 100 | assertThrows(() => cleanEnv({ FOO: "" }, spec, makeSilent)); 101 | assertThrows(() => cleanEnv({ FOO: "0" }, spec, makeSilent)); 102 | assertThrows(() => cleanEnv({ FOO: "65536" }, spec, makeSilent)); 103 | assertThrows(() => cleanEnv({ FOO: "042" }, spec, makeSilent)); 104 | assertThrows(() => cleanEnv({ FOO: "42.0" }, spec, makeSilent)); 105 | assertThrows(() => cleanEnv({ FOO: "42.42" }, spec, makeSilent)); 106 | assertThrows(() => cleanEnv({ FOO: "hello" }, spec, makeSilent)); 107 | }); 108 | 109 | Deno.test("json()", () => { 110 | const env = cleanEnv({ FOO: '{"x": 123}' }, { FOO: json() }); 111 | assertEquals(env, { FOO: { x: 123 } }); 112 | 113 | assertThrows(() => cleanEnv({ FOO: "abc" }, { FOO: json() }, makeSilent)); 114 | 115 | // default value should be passed through without running through JSON.parse() 116 | assertEquals( 117 | cleanEnv( 118 | {}, 119 | { 120 | FOO: json({ default: { x: 999 } }), 121 | }, 122 | ), 123 | { FOO: { x: 999 } }, 124 | ); 125 | }); 126 | 127 | Deno.test("url()", () => { 128 | assertPassthrough({ FOO: "http://foo.com" }, { FOO: url() }); 129 | assertPassthrough({ FOO: "http://foo.com/bar/baz" }, { FOO: url() }); 130 | assertPassthrough({ FOO: "custom://foo.com/bar/baz?hi=1" }, { FOO: url() }); 131 | 132 | assertThrows(() => cleanEnv({ FOO: "abc" }, { FOO: url() }, makeSilent)); 133 | }); 134 | 135 | Deno.test("str()", () => { 136 | const withEmpty = cleanEnv({ FOO: "" }, { FOO: str() }); 137 | assertEquals(withEmpty, { FOO: "" }); 138 | 139 | assertThrows(() => cleanEnv({ FOO: 42 }, { FOO: str() }, makeSilent)); 140 | }); 141 | 142 | Deno.test("custom validators", () => { 143 | const alwaysFoo = makeValidator((_x) => "foo"); 144 | 145 | const fooEnv = cleanEnv({ FOO: "asdf" }, { FOO: alwaysFoo() }); 146 | assertEquals(fooEnv, { FOO: "foo" }); 147 | 148 | const hex10 = makeValidator((x) => { 149 | if (/^[a-f0-9]{10}$/.test(x)) return x; 150 | throw new Error("need 10 hex chars"); 151 | }); 152 | assertPassthrough({ FOO: "a0d9aacbde" }, { FOO: hex10() }); 153 | assertThrows( 154 | () => cleanEnv({ FOO: "abc" }, { FOO: hex10() }, makeSilent), 155 | Error, 156 | ); 157 | 158 | // Default values work with custom validators as well 159 | const withDefault = cleanEnv({}, { FOO: hex10({ default: "abcabcabc0" }) }); 160 | assertEquals(withDefault, { FOO: "abcabcabc0" }); 161 | }); 162 | --------------------------------------------------------------------------------