From d18c23f34bbe929f0e704de8ce9871353ee31e55 Mon Sep 17 00:00:00 2001 From: Vladimir AI Date: Sat, 8 Aug 2026 09:16:05 +0300 Subject: [PATCH 1/7] feat(#251)!: treat empty strings as missing environment variables --- src/core.ts | 35 +++++++++++++------- tests/requiredWhen.test.ts | 66 +++++++++++++++++++++++++++++++------- tests/validators.test.ts | 18 +++++++++-- 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/src/core.ts b/src/core.ts index a53fa52..297f98a 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,6 +1,6 @@ import { EnvError, EnvMissingError } from './errors' -import type { CleanOptions, SpecsOutput, Spec, ValidatorSpec } from './types' import { defaultReporter } from './reporter' +import type { CleanOptions, Spec, SpecsOutput, ValidatorSpec } from './types' /** * Validate a single env var, given a spec object @@ -11,16 +11,16 @@ import { defaultReporter } from './reporter' function validateVar({ spec, name, - rawValue, + normalizedValue, }: { name: string - rawValue: string | T + normalizedValue: string | T spec: ValidatorSpec }) { if (typeof spec._parse !== 'function') { throw new EnvError(`Invalid spec for "${name}"`) } - const value = spec._parse(rawValue as string) + const value = spec._parse(normalizedValue as string) if (spec.choices) { if (!Array.isArray(spec.choices)) { @@ -40,8 +40,17 @@ export function formatSpecDescription(spec: Spec) { return `${spec.desc}${egText}${docsText}` } -const readRawEnvValue = (env: unknown, k: keyof T | 'NODE_ENV'): string | T[keyof T] => { - return (env as any)[k] +const readNormalizedEnvValue = ( + env: unknown, + k: keyof T | 'NODE_ENV', +): string | undefined | T[keyof T] => { + const result = (env as any)[k] + + if (typeof result == 'string' && !result.trim()) { + return undefined + } + + return result } /** @@ -56,25 +65,27 @@ export function getSanitizedEnv( const castedSpecs = specs as unknown as Record> const errors = {} as Record const varKeys = Object.keys(castedSpecs) as Array - const rawNodeEnv = readRawEnvValue(environment, 'NODE_ENV') + const normalizedNodeEnv = readNormalizedEnvValue(environment, 'NODE_ENV') for (const k of varKeys) { const spec = castedSpecs[k] - const rawValue = readRawEnvValue(environment, k) + const normalizedValue = readNormalizedEnvValue(environment, k) try { // If no value was given and default/devDefault/testDefault were provided, return the // appropriate default value without passing it through validation - if (rawValue === undefined) { + if (normalizedValue === undefined) { // Use testDefault only when NODE_ENV is 'test'. Takes priority over devDefault and default. - if (rawNodeEnv === 'test' && Object.hasOwn(spec, 'testDefault')) { + if (normalizedNodeEnv === 'test' && Object.hasOwn(spec, 'testDefault')) { cleanedEnv[k] = spec.testDefault continue } // Use devDefault values only if NODE_ENV was explicitly set, and isn't 'production' const usingDevDefault = - rawNodeEnv && rawNodeEnv !== 'production' && Object.hasOwn(spec, 'devDefault') + normalizedNodeEnv && + normalizedNodeEnv !== 'production' && + Object.hasOwn(spec, 'devDefault') if (usingDevDefault) { cleanedEnv[k] = spec.devDefault @@ -91,7 +102,7 @@ export function getSanitizedEnv( throw new EnvMissingError(formatSpecDescription(spec)) } - cleanedEnv[k] = validateVar({ name: k as string, spec, rawValue }) + cleanedEnv[k] = validateVar({ name: k as string, spec, normalizedValue }) } catch (err) { if (options?.reporter === null) throw err if (err instanceof Error) errors[k] = err diff --git a/tests/requiredWhen.test.ts b/tests/requiredWhen.test.ts index 2389d6e..19d237e 100644 --- a/tests/requiredWhen.test.ts +++ b/tests/requiredWhen.test.ts @@ -2,9 +2,13 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { bool, cleanEnv, defaultReporter, EnvMissingError, num, EnvError } from '../src' import { formatSpecDescription } from '../src/core' -vi.mock('../src/reporter') -const mockedDefaultReporter: vi.Mock = >defaultReporter; -mockedDefaultReporter.mockImplementation(() => { }) +const mockedDefaultReporter = >vi.fn() +mockedDefaultReporter.mockImplementation(() => {}) + +vi.mock('../src/reporter', (): typeof import('../src/reporter') => ({ + defaultReporter: mockedDefaultReporter, + envalidErrorFormatter: vi.fn(), +})) describe('requiredWhen', () => { beforeEach(() => { @@ -13,7 +17,7 @@ describe('requiredWhen', () => { test("isn't required", () => { cleanEnv( { - autoExtractId: "true", + autoExtractId: 'true', }, { autoExtractId: bool(), @@ -29,14 +33,54 @@ describe('requiredWhen', () => { autoExtractId: true, id: undefined, }, - errors: {} + errors: {}, + }) + }) + + test("isn't required but empty string provided", () => { + cleanEnv( + { + id: '', + }, + { + id: num({ + default: undefined, + requiredWhen: () => false, + }), + }, + ) + expect(mockedDefaultReporter).toHaveBeenCalledTimes(1) + expect(mockedDefaultReporter).toHaveBeenCalledWith({ + env: { + id: undefined, + }, + errors: {}, + }) + + cleanEnv( + { + autoExtractId: '', + }, + { + autoExtractId: bool({ + default: undefined, + requiredWhen: () => false, + }), + }, + ) + expect(mockedDefaultReporter).toHaveBeenCalledTimes(2) + expect(mockedDefaultReporter).toHaveBeenCalledWith({ + env: { + autoExtractId: undefined, + }, + errors: {}, }) }) test('required but not provided', () => { cleanEnv( { - autoExtractId: "false", + autoExtractId: 'false', }, { autoExtractId: bool(), @@ -68,8 +112,8 @@ describe('requiredWhen', () => { test('required and provided', () => { cleanEnv( { - autoExtractId: "false", - id: "123" + autoExtractId: 'false', + id: '123', }, { autoExtractId: bool(), @@ -92,8 +136,8 @@ describe('requiredWhen', () => { test('required but failed to parse', () => { cleanEnv( { - autoExtractId: "false", - id: "abc" + autoExtractId: 'false', + id: 'abc', }, { autoExtractId: bool(), @@ -110,7 +154,7 @@ describe('requiredWhen', () => { id: undefined, }, errors: { - id: new EnvError(`Invalid number input: "abc"`) + id: new EnvError(`Invalid number input: "abc"`), }, }) }) diff --git a/tests/validators.test.ts b/tests/validators.test.ts index 60ead85..9acb6f4 100644 --- a/tests/validators.test.ts +++ b/tests/validators.test.ts @@ -39,8 +39,19 @@ test('bool() works with various formats', () => { const off = cleanEnv({ FOO: 'off' }, { FOO: bool() }) expect(off).toEqual({ FOO: false }) + expect(function withEmpty() { + return cleanEnv({ FOO: '' }, { FOO: bool() }, makeSilent) + }).toThrow() + const defaultF = cleanEnv({}, { FOO: bool({ default: false }) }) expect(defaultF).toEqual({ FOO: false }) + + const defaultTWithWhitespace = cleanEnv( + { FOO: ' ' }, + { FOO: bool({ default: true }) }, + makeSilent, + ) + expect(defaultTWithWhitespace).toEqual({ FOO: true }) }) test('num()', () => { @@ -132,8 +143,11 @@ test('url()', () => { }) test('str()', () => { - const withEmpty = cleanEnv({ FOO: '' }, { FOO: str() }) - expect(withEmpty).toEqual({ FOO: '' }) + expect(cleanEnv({ FOO: 'asdf' }, { FOO: str() })).toEqual({ FOO: 'asdf' }) + + expect(function withWhitespace() { + return cleanEnv({ FOO: ' ' }, { FOO: str() }, makeSilent) + }).toThrow() expect(() => cleanEnv({ FOO: 42 }, { FOO: str() }, makeSilent)).toThrow() }) From f7b2046debc755f62ad4ac63f2a72d4301aafc93 Mon Sep 17 00:00:00 2001 From: Vladimir AI Date: Sat, 8 Aug 2026 11:36:13 +0300 Subject: [PATCH 2/7] feat(af#251)!: fix README --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 6255165..f8041c5 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,7 @@ Node's `process.env` only stores strings, but sometimes you want to retrieve oth URL, email address). To these ends, the following validation functions are available: - `str()` - Passes string values through, will ensure a value is present unless a - `default` value is given. Note that an empty string is considered a valid value - - if this is undesirable you can easily create your own validator (see below) + `default` value is given. Note that an empty string is not considered a valid value - `bool()` - Parses env var strings `"1", "0", "true", "false", "t", "f", "yes", "no", "on", "off"` into booleans - `num()` - Parses an env var (eg. `"42", "0.23", "1e5"`) into a Number - `email()` - Ensures an env var is an email address From 9f6090bf99f71615b7a63cabdf4f1f483e5b1d66 Mon Sep 17 00:00:00 2001 From: Vladimir AI Date: Wed, 12 Aug 2026 07:32:48 +0300 Subject: [PATCH 3/7] feat(af#251)!: fix tests (the correct command was `bun run test`, not `bun test`) --- tests/requiredWhen.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/requiredWhen.test.ts b/tests/requiredWhen.test.ts index 19d237e..e1ac8f4 100644 --- a/tests/requiredWhen.test.ts +++ b/tests/requiredWhen.test.ts @@ -2,14 +2,10 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { bool, cleanEnv, defaultReporter, EnvMissingError, num, EnvError } from '../src' import { formatSpecDescription } from '../src/core' -const mockedDefaultReporter = >vi.fn() +vi.mock('../src/reporter') +const mockedDefaultReporter: vi.Mock = >defaultReporter mockedDefaultReporter.mockImplementation(() => {}) -vi.mock('../src/reporter', (): typeof import('../src/reporter') => ({ - defaultReporter: mockedDefaultReporter, - envalidErrorFormatter: vi.fn(), -})) - describe('requiredWhen', () => { beforeEach(() => { mockedDefaultReporter.mockClear() From 25e2e8df9bf76cc18512380de5ede5a0eb5455ac Mon Sep 17 00:00:00 2001 From: Vladimir AI Date: Fri, 21 Aug 2026 13:33:10 +0300 Subject: [PATCH 4/7] feat(#251)!: add symmetric test for str(), revert to rawValue --- src/core.ts | 16 ++++++++-------- tests/validators.test.ts | 7 +++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/core.ts b/src/core.ts index 297f98a..1ed69c7 100644 --- a/src/core.ts +++ b/src/core.ts @@ -11,16 +11,16 @@ import type { CleanOptions, Spec, SpecsOutput, ValidatorSpec } from './types' function validateVar({ spec, name, - normalizedValue, + rawValue, }: { name: string - normalizedValue: string | T + rawValue: string | T spec: ValidatorSpec }) { if (typeof spec._parse !== 'function') { throw new EnvError(`Invalid spec for "${name}"`) } - const value = spec._parse(normalizedValue as string) + const value = spec._parse(rawValue as string) if (spec.choices) { if (!Array.isArray(spec.choices)) { @@ -40,7 +40,7 @@ export function formatSpecDescription(spec: Spec) { return `${spec.desc}${egText}${docsText}` } -const readNormalizedEnvValue = ( +const readRawEnvValue = ( env: unknown, k: keyof T | 'NODE_ENV', ): string | undefined | T[keyof T] => { @@ -65,16 +65,16 @@ export function getSanitizedEnv( const castedSpecs = specs as unknown as Record> const errors = {} as Record const varKeys = Object.keys(castedSpecs) as Array - const normalizedNodeEnv = readNormalizedEnvValue(environment, 'NODE_ENV') + const normalizedNodeEnv = readRawEnvValue(environment, 'NODE_ENV') for (const k of varKeys) { const spec = castedSpecs[k] - const normalizedValue = readNormalizedEnvValue(environment, k) + const rawValue = readRawEnvValue(environment, k) try { // If no value was given and default/devDefault/testDefault were provided, return the // appropriate default value without passing it through validation - if (normalizedValue === undefined) { + if (rawValue === undefined) { // Use testDefault only when NODE_ENV is 'test'. Takes priority over devDefault and default. if (normalizedNodeEnv === 'test' && Object.hasOwn(spec, 'testDefault')) { cleanedEnv[k] = spec.testDefault @@ -102,7 +102,7 @@ export function getSanitizedEnv( throw new EnvMissingError(formatSpecDescription(spec)) } - cleanedEnv[k] = validateVar({ name: k as string, spec, normalizedValue }) + cleanedEnv[k] = validateVar({ name: k as string, spec, rawValue }) } catch (err) { if (options?.reporter === null) throw err if (err instanceof Error) errors[k] = err diff --git a/tests/validators.test.ts b/tests/validators.test.ts index 9acb6f4..59c2851 100644 --- a/tests/validators.test.ts +++ b/tests/validators.test.ts @@ -168,4 +168,11 @@ test('custom types', () => { // Default values work with custom validators as well const withDefault = cleanEnv({}, { FOO: hex10({ default: 'abcabcabc0' }) }) expect(withDefault).toEqual({ FOO: 'abcabcabc0' }) + + const defaultWithWhitespace = cleanEnv( + { FOO: ' ' }, + { FOO: str({ default: "asdf" }) }, + makeSilent, + ) + expect(defaultWithWhitespace).toEqual({ FOO: "asdf" }) }) From eb60fc2994d00bcdfbc6d0d634f8b2c7c58bf13a Mon Sep 17 00:00:00 2001 From: Vladimir AI Date: Fri, 21 Aug 2026 13:41:29 +0300 Subject: [PATCH 5/7] feat(#251)!: normalizedNodeEnv -> rawNodeEnv as well, fix formatting --- src/core.ts | 8 +++----- tests/validators.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/core.ts b/src/core.ts index 1ed69c7..fedc0b2 100644 --- a/src/core.ts +++ b/src/core.ts @@ -65,7 +65,7 @@ export function getSanitizedEnv( const castedSpecs = specs as unknown as Record> const errors = {} as Record const varKeys = Object.keys(castedSpecs) as Array - const normalizedNodeEnv = readRawEnvValue(environment, 'NODE_ENV') + const rawNodeEnv = readRawEnvValue(environment, 'NODE_ENV') for (const k of varKeys) { const spec = castedSpecs[k] @@ -76,16 +76,14 @@ export function getSanitizedEnv( // appropriate default value without passing it through validation if (rawValue === undefined) { // Use testDefault only when NODE_ENV is 'test'. Takes priority over devDefault and default. - if (normalizedNodeEnv === 'test' && Object.hasOwn(spec, 'testDefault')) { + if (rawNodeEnv === 'test' && Object.hasOwn(spec, 'testDefault')) { cleanedEnv[k] = spec.testDefault continue } // Use devDefault values only if NODE_ENV was explicitly set, and isn't 'production' const usingDevDefault = - normalizedNodeEnv && - normalizedNodeEnv !== 'production' && - Object.hasOwn(spec, 'devDefault') + rawNodeEnv && rawNodeEnv !== 'production' && Object.hasOwn(spec, 'devDefault') if (usingDevDefault) { cleanedEnv[k] = spec.devDefault diff --git a/tests/validators.test.ts b/tests/validators.test.ts index 59c2851..1232d52 100644 --- a/tests/validators.test.ts +++ b/tests/validators.test.ts @@ -171,8 +171,8 @@ test('custom types', () => { const defaultWithWhitespace = cleanEnv( { FOO: ' ' }, - { FOO: str({ default: "asdf" }) }, + { FOO: str({ default: 'asdf' }) }, makeSilent, ) - expect(defaultWithWhitespace).toEqual({ FOO: "asdf" }) + expect(defaultWithWhitespace).toEqual({ FOO: 'asdf' }) }) From 7654c4b3cce5ea231906aa2b8636a6c57187fd1f Mon Sep 17 00:00:00 2001 From: Vladimir AI Date: Fri, 21 Aug 2026 13:58:33 +0300 Subject: [PATCH 6/7] feat(#251)!: fix wrong test location --- tests/validators.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/validators.test.ts b/tests/validators.test.ts index 1232d52..2a78dfe 100644 --- a/tests/validators.test.ts +++ b/tests/validators.test.ts @@ -150,6 +150,13 @@ test('str()', () => { }).toThrow() expect(() => cleanEnv({ FOO: 42 }, { FOO: str() }, makeSilent)).toThrow() + + const defaultWithWhitespace = cleanEnv( + { FOO: ' ' }, + { FOO: str({ default: 'asdf' }) }, + makeSilent, + ) + expect(defaultWithWhitespace).toEqual({ FOO: 'asdf' }) }) test('custom types', () => { @@ -168,11 +175,4 @@ test('custom types', () => { // Default values work with custom validators as well const withDefault = cleanEnv({}, { FOO: hex10({ default: 'abcabcabc0' }) }) expect(withDefault).toEqual({ FOO: 'abcabcabc0' }) - - const defaultWithWhitespace = cleanEnv( - { FOO: ' ' }, - { FOO: str({ default: 'asdf' }) }, - makeSilent, - ) - expect(defaultWithWhitespace).toEqual({ FOO: 'asdf' }) }) From 6d6b5d8c82c7db8983b07d69ad4e1065d0b1d13a Mon Sep 17 00:00:00 2001 From: Vladimir AI Date: Fri, 21 Aug 2026 14:01:23 +0300 Subject: [PATCH 7/7] feat(#251)!: reorder tests a bit --- tests/validators.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/validators.test.ts b/tests/validators.test.ts index 2a78dfe..1ea1d14 100644 --- a/tests/validators.test.ts +++ b/tests/validators.test.ts @@ -149,14 +149,14 @@ test('str()', () => { return cleanEnv({ FOO: ' ' }, { FOO: str() }, makeSilent) }).toThrow() - expect(() => cleanEnv({ FOO: 42 }, { FOO: str() }, makeSilent)).toThrow() - const defaultWithWhitespace = cleanEnv( { FOO: ' ' }, { FOO: str({ default: 'asdf' }) }, makeSilent, ) expect(defaultWithWhitespace).toEqual({ FOO: 'asdf' }) + + expect(() => cleanEnv({ FOO: 42 }, { FOO: str() }, makeSilent)).toThrow() }) test('custom types', () => {