Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ Each validation function accepts an (optional) object with the following attribu
- `devDefault` - A fallback value to use _only_ when `NODE_ENV` is explicitly set and _not_ `'production'`.
This is handy for env vars that are required for production environments, but optional
for development and testing.
- `testDefault` - A fallback value to use _only_ when `NODE_ENV=test`. When provided, it takes
priority over both `devDefault` and `default` in test environments. Unlike the `testOnly()`
helper (which is wrapped around a `devDefault` and therefore can't coexist with a separate
dev value), `testDefault` can be combined with `default` and `devDefault` to provide
distinct values for production, development, and test.
- `desc` - A string that describes the env var.
- `example` - An example value for the env var.
- `docs` - A URL that leads to more detailed documentation about the env var.
Expand Down Expand Up @@ -220,14 +225,26 @@ argument required in the third position:

### testOnly

The `testOnly` helper function is available for setting a default value for an env var only when `NODE_ENV=test`. It is recommended to use this function along with `devDefault`. For example:
The `testOnly` helper function is available for setting a default value for an env var only when `NODE_ENV=test`. It is used by wrapping a `devDefault` value:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think (after this PR) it probably makes sense to just remove the testOnly helper– can you think of any remaining use for it? It's always been a bit awkward

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I don't see any remaining use cases for it. In fact I've been using envalid for more than 10 years (thanks for maintaining it!) and never used testOnly. I always wished there was a testDefault as my workflow is often to have no prod default but both dev & test defaults.

I would suggest to first release testDefault as a minor release and remove testOnly for the next major (I'm sure that's what you have in mind).

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I just released v8.2.0, and v9 will follow in the next few days with testDefault removed and a few other small breaking changes


```js
const env = cleanEnv(process.env, {
SOME_VAR: envalid.str({ devDefault: testOnly('myTestValue') }),
})
```

Because `testOnly` is applied via `devDefault`, you cannot use it to express both a development default _and_ a separate test default. For that, prefer the `testDefault` spec attribute, which can be combined freely with `default` and `devDefault`:

```js
const env = cleanEnv(process.env, {
SOME_VAR: envalid.str({
default: 'productionValue',
devDefault: 'devValue',
testDefault: 'myTestValue',
}),
})
```

For more context see [this issue](https://github.com/af/envalid/issues/32).

## FAQ
Expand Down
10 changes: 8 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,15 @@ export function getSanitizedEnv<S>(
const rawValue = readRawEnvValue(environment, k)

try {
// If no value was given and default/devDefault were provided, return the appropriate default
// value without passing it through validation
// If no value was given and default/devDefault/testDefault were provided, return the
// 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 (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 =
rawNodeEnv && rawNodeEnv !== 'production' && Object.hasOwn(spec, 'devDefault')
Expand Down
23 changes: 22 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface Spec<T> {
* This is handy for env vars that are required for production environments, but optional for development and testing.
*/
devDefault?: NonNullable<T> | undefined
/**
* A fallback value to use only when NODE_ENV is 'test'. Takes priority over `devDefault` and `default`.
* Unlike the `testOnly()` helper (which wraps a `devDefault` value), `testDefault` can be combined
* with both `default` and `devDefault` to specify distinct values for production, development, and test.
*/
testDefault?: NonNullable<T> | undefined

/**
* A function (env -> boolean) that allows an env var to be required only when certain
Expand All @@ -35,16 +41,31 @@ export interface Spec<T> {
type OptionalAttrs<T> =
| { default: undefined }
| { devDefault: undefined }
| { testDefault: undefined }
| { default: undefined; devDefault: undefined }
| { default: undefined; testDefault: undefined }
| { devDefault: undefined; testDefault: undefined }
| { default: undefined; devDefault: undefined; testDefault: undefined }
| { default: NonNullable<T>; devDefault: undefined }
| { default: NonNullable<T>; testDefault: undefined }
| { devDefault: NonNullable<T>; testDefault: undefined }
| { default: undefined; devDefault: NonNullable<T> }
| { default: undefined; testDefault: NonNullable<T> }
| { devDefault: undefined; testDefault: NonNullable<T> }
| { default: NonNullable<T>; devDefault: NonNullable<T>; testDefault: undefined }
| { default: NonNullable<T>; devDefault: undefined; testDefault: NonNullable<T> }
| { default: undefined; devDefault: NonNullable<T>; testDefault: NonNullable<T> }
type RequiredAttrs<T> =
| { default: NonNullable<T> }
| { devDefault: NonNullable<T> }
| { testDefault: NonNullable<T> }
| { devDefault: NonNullable<T>; default: NonNullable<T> }
| { testDefault: NonNullable<T>; default: NonNullable<T> }
| { testDefault: NonNullable<T>; devDefault: NonNullable<T> }
| { testDefault: NonNullable<T>; devDefault: NonNullable<T>; default: NonNullable<T> }
| {}

type DefaultKeys = 'default' | 'devDefault'
type DefaultKeys = 'default' | 'devDefault' | 'testDefault'

type OptionalSpec<T> = Spec<T> & OptionalAttrs<T>
type OptionalTypelessSpec = Omit<OptionalSpec<unknown>, 'choices'>
Expand Down
73 changes: 73 additions & 0 deletions tests/basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,79 @@ test('falsy devDefault', () => {
expect(() => cleanEnv({ NODE_ENV: 'production' }, spec, makeSilent)).toThrow()
})

test('testDefault', () => {
const spec = {
FOO: str({ testDefault: 'hi' }),
}

// testDefault is used when NODE_ENV is 'test'
const env = cleanEnv({ NODE_ENV: 'test' }, spec)
expect(env).toEqual({ FOO: 'hi' })

// testDefault is not used in production (and the field has no other default, so it throws)
expect(() => cleanEnv({ NODE_ENV: 'production' }, spec, makeSilent)).toThrow()

// testDefault is not used in development either
expect(() => cleanEnv({ NODE_ENV: 'development' }, spec, makeSilent)).toThrow()
})

test('testDefault set to undefined', () => {
const env = cleanEnv(
{ NODE_ENV: 'test' },
{
FOO: str({ testDefault: undefined }),
},
)
expect(env).toEqual({ FOO: undefined })
})

test('falsy testDefault', () => {
const spec = {
FOO: str({ testDefault: '' }),
}

const env = cleanEnv({ NODE_ENV: 'test' }, spec)
expect(env).toEqual({ FOO: '' })

expect(() => cleanEnv({ NODE_ENV: 'production' }, spec, makeSilent)).toThrow()
})

test('testDefault takes priority over devDefault and default in test', () => {
const spec = {
FOO: str({ default: 'prod', devDefault: 'dev', testDefault: 'test' }),
}

expect(cleanEnv({ NODE_ENV: 'test' }, spec)).toEqual({ FOO: 'test' })
expect(cleanEnv({ NODE_ENV: 'development' }, spec)).toEqual({ FOO: 'dev' })
expect(cleanEnv({ NODE_ENV: 'production' }, spec)).toEqual({ FOO: 'prod' })
})

test('testDefault falls back to devDefault/default outside of test', () => {
// testDefault alongside default: outside test, uses default
expect(
cleanEnv(
{ NODE_ENV: 'production' },
{ FOO: str({ default: 'prod', testDefault: 'test' }) },
),
).toEqual({ FOO: 'prod' })

// testDefault alongside devDefault: in development, uses devDefault
expect(
cleanEnv(
{ NODE_ENV: 'development' },
{ FOO: str({ devDefault: 'dev', testDefault: 'test' }) },
),
).toEqual({ FOO: 'dev' })
})

test('testDefault overrides default in test even when devDefault absent', () => {
const env = cleanEnv(
{ NODE_ENV: 'test' },
{ FOO: str({ default: 'prod', testDefault: 'test' }) },
)
expect(env).toEqual({ FOO: 'test' })
})

test('devDefault and default together', () => {
const spec = {
FOO: num({ devDefault: 3000, default: 80 }),
Expand Down
32 changes: 32 additions & 0 deletions tests/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ describe('validators types', () => {
OptionalValidatorSpec<boolean>
>()
expectTypeOf(validator({ devDefault: false })).toEqualTypeOf<RequiredValidatorSpec<boolean>>()
expectTypeOf(validator({ testDefault: undefined })).toEqualTypeOf<
OptionalValidatorSpec<boolean>
>()
expectTypeOf(validator({ testDefault: false })).toEqualTypeOf<RequiredValidatorSpec<boolean>>()
})

test('number-based validators', () => {
Expand Down Expand Up @@ -90,6 +94,12 @@ describe('validators types', () => {
OptionalValidatorSpec<number>
>()
expectTypeOf(validator<2>({ devDefault: 2 })).toEqualTypeOf<RequiredValidatorSpec<2>>()
expectTypeOf(validator({ testDefault: 0 })).toEqualTypeOf<RequiredValidatorSpec<number>>()
expectTypeOf(validator({ testDefault: undefined })).toEqualTypeOf<
OptionalValidatorSpec<number>
>()
// @ts-expect-error - 3 is not assignable to 1 | 2
validator({ choices: [1, 2], testDefault: 3 })
})
test('string-based validators', () => {
const validator = makeValidator<string>(() => '')
Expand Down Expand Up @@ -161,6 +171,20 @@ describe('validators types', () => {
expectTypeOf(
validator<'foo' | 'bar'>({ choices: ['foo', 'bar'], devDefault: 'bar' }),
).toEqualTypeOf<RequiredValidatorSpec<'foo' | 'bar'>>()
expectTypeOf(validator({ testDefault: 'foo' })).toEqualTypeOf<RequiredValidatorSpec<string>>()
expectTypeOf(validator({ testDefault: undefined })).toEqualTypeOf<
OptionalValidatorSpec<string>
>()
expectTypeOf(
validator({ default: 'foo', devDefault: 'foo', testDefault: 'foo' }),
).toEqualTypeOf<RequiredValidatorSpec<string>>()
expectTypeOf(
validator<'foo' | 'bar' | 'baz'>({
default: 'foo',
devDefault: 'bar',
testDefault: 'baz',
}),
).toEqualTypeOf<RequiredValidatorSpec<'foo' | 'bar' | 'baz'>>()
})
test('structured data validator', () => {
const validator = makeStructuredValidator(() => ({}))
Expand Down Expand Up @@ -215,6 +239,8 @@ test('cleanEnv', () => {
STR: 'FOO',
STR_OPT: undefined,
STR_DEV_DEFAULT_UDEF: undefined,
STR_TEST_DEFAULT: 'value',
STR_TEST_DEFAULT_UDEF: 'value',
STR_CHOICES: 'foo',
STR_REQ: 'BAR',
STR_DEFAULT_CHOICES: 'bar',
Expand All @@ -239,6 +265,8 @@ test('cleanEnv', () => {
STR_CHOICES: str({ choices: ['foo', 'bar'] }),
STR_REQ: str({ default: 'foo' }),
STR_DEFAULT_CHOICES: str({ default: 'foo', choices: ['foo', 'bar'] }),
STR_TEST_DEFAULT: str({ testDefault: 'foo' }),
STR_TEST_DEFAULT_UDEF: str({ testDefault: undefined }),
BOOL: bool(),
BOOL_OPT: bool({ default: undefined }),
BOOL_DEV_DEFAULT: bool({ devDefault: undefined }),
Expand All @@ -261,6 +289,8 @@ test('cleanEnv', () => {
STR_CHOICES: 'foo' | 'bar'
STR_REQ: string
STR_DEFAULT_CHOICES: 'foo' | 'bar'
STR_TEST_DEFAULT: string
STR_TEST_DEFAULT_UDEF: string | undefined
BOOL: boolean
BOOL_OPT: boolean | undefined
BOOL_DEFAULT: boolean
Expand All @@ -286,6 +316,8 @@ test('cleanEnv', () => {
STR_CHOICES: str({ choices: ['foo', 'bar'] }),
STR_REQ: str({ default: 'foo' }),
STR_DEFAULT_CHOICES: str({ default: 'foo', choices: ['foo', 'bar'] }),
STR_TEST_DEFAULT: str({ testDefault: 'foo' }),
STR_TEST_DEFAULT_UDEF: str({ testDefault: undefined }),
BOOL: bool(),
BOOL_OPT: bool({ default: undefined }),
BOOL_DEV_DEFAULT: bool({ devDefault: undefined }),
Expand Down
Loading