Skip to content

Commit 763cdc3

Browse files
committed
refactor(env): unify envAsBoolean/Number/String via options to eliminate duplication
Org-scan R4 #2: env.ts and env/helpers.ts exported the same three names with different semantics. Rather than preserving silent divergence, the root implementations now accept options to select either mode: - envAsBoolean(value, { trim }) — trim=true default; trim=false = strict - envAsNumber(value, { mode, allowInfinity }) — 'int' default; 'float' | allowInfinity for helpers parity - envAsString(value, { trim }) — trim=true default; trim=false preserves whitespace Legacy positional-default arguments still work (envAsNumber(null, 42), envAsString(null, 'x'), etc.). envAsString also coerces non-string positional defaults via String() for back-compat with existing tests. env/helpers.ts is now a thin wrapper that delegates to the root functions with the historical strict/float/preserve-whitespace modes pre-selected. All 132 env tests pass (both root and helpers suites).
1 parent 3ac9f91 commit 763cdc3

2 files changed

Lines changed: 185 additions & 62 deletions

File tree

src/env.ts

Lines changed: 157 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const NumberCtor = Number
99
// `exports.SomeName = void 0;` which causes runtime errors.
1010
// See: https://github.com/SocketDev/socket-packageurl-js/issues/3
1111
const NumberIsFinite = Number.isFinite
12+
const NumberIsNaN = Number.isNaN
1213
const NumberParseInt = Number.parseInt
1314
const StringCtor = String
1415

@@ -173,31 +174,59 @@ export function createEnvProxy(
173174
}) as NodeJS.ProcessEnv
174175
}
175176

177+
/**
178+
* Options for `envAsBoolean`.
179+
*/
180+
export interface EnvAsBooleanOptions {
181+
/** Default when value is null/undefined. @default false */
182+
defaultValue?: boolean | undefined
183+
/**
184+
* Whether to trim whitespace from string values before matching. When
185+
* `false`, `' true '` is NOT recognised as truthy — only exact matches.
186+
* @default true
187+
*/
188+
trim?: boolean | undefined
189+
}
190+
176191
/**
177192
* Convert an environment variable value to a boolean.
178193
*
194+
* Back-compat overload: passing a bare boolean as the second argument is
195+
* equivalent to `{ defaultValue: B }`.
196+
*
179197
* @param value - The value to convert
180-
* @param defaultValue - Default when value is null/undefined (default: `false`)
181-
* @returns `true` if value is '1' or 'true' (case-insensitive), `false` otherwise
198+
* @param defaultValueOrOptions - Default (boolean) or options object
199+
* @returns `true` if value is '1', 'true', or 'yes' (case-insensitive), `false` otherwise
182200
*
183201
* @example
184202
* ```typescript
185203
* import { envAsBoolean } from '@socketsecurity/lib/env'
186204
*
187-
* envAsBoolean('true') // true
188-
* envAsBoolean('1') // true
189-
* envAsBoolean('false') // false
190-
* envAsBoolean(undefined) // false
205+
* envAsBoolean('true') // true
206+
* envAsBoolean('1') // true
207+
* envAsBoolean('yes') // true
208+
* envAsBoolean(' true ') // true (trimmed)
209+
* envAsBoolean(' true ', { trim: false }) // false (strict)
210+
* envAsBoolean(undefined) // false
211+
* envAsBoolean(undefined, true) // true (legacy positional default)
191212
* ```
192213
*/
193214
/*@__NO_SIDE_EFFECTS__*/
194-
export function envAsBoolean(value: unknown, defaultValue = false): boolean {
215+
export function envAsBoolean(
216+
value: unknown,
217+
defaultValueOrOptions: boolean | EnvAsBooleanOptions | undefined = false,
218+
): boolean {
219+
const opts: EnvAsBooleanOptions =
220+
typeof defaultValueOrOptions === 'boolean'
221+
? { defaultValue: defaultValueOrOptions }
222+
: (defaultValueOrOptions ?? {})
223+
const { defaultValue = false, trim = true } = opts
195224
if (typeof value === 'string') {
196-
const trimmed = value.trim()
197-
if (!trimmed) {
225+
const candidate = trim ? value.trim() : value
226+
if (!candidate) {
198227
return !!defaultValue
199228
}
200-
const lower = trimmed.toLowerCase()
229+
const lower = candidate.toLowerCase()
201230
return lower === '1' || lower === 'true' || lower === 'yes'
202231
}
203232
if (value === null || value === undefined) {
@@ -206,25 +235,80 @@ export function envAsBoolean(value: unknown, defaultValue = false): boolean {
206235
return !!value
207236
}
208237

238+
/**
239+
* Options for `envAsNumber`.
240+
*/
241+
export interface EnvAsNumberOptions {
242+
/**
243+
* Whether to return `±Infinity` when input parses to infinity. When
244+
* `false` (default), infinities and NaN are coerced to `defaultValue`.
245+
* @default false
246+
*/
247+
allowInfinity?: boolean | undefined
248+
/** Default when value is not a finite number. @default 0 */
249+
defaultValue?: number | undefined
250+
/**
251+
* Parse mode. `'int'` (default) uses `parseInt(_, 10)` — integer only.
252+
* `'float'` uses `Number()` — decimals preserved.
253+
* @default 'int'
254+
*/
255+
mode?: 'int' | 'float' | undefined
256+
}
257+
209258
/**
210259
* Convert an environment variable value to a number.
211260
*
261+
* Back-compat overload: passing a bare number as the second argument is
262+
* equivalent to `{ defaultValue: N }`.
263+
*
212264
* @param value - The value to convert
213-
* @param defaultValue - Default when value is not a finite number (default: `0`)
214-
* @returns The parsed integer, or the default value if parsing fails
265+
* @param defaultValueOrOptions - Default (number) or options object
266+
* @returns The parsed number, or the default value if parsing fails
215267
*
216268
* @example
217269
* ```typescript
218270
* import { envAsNumber } from '@socketsecurity/lib/env'
219271
*
220-
* envAsNumber('3000') // 3000
221-
* envAsNumber('abc') // 0
222-
* envAsNumber(undefined) // 0
272+
* envAsNumber('3000') // 3000 (int mode)
273+
* envAsNumber('3.14', { mode: 'float' }) // 3.14
274+
* envAsNumber('abc') // 0
275+
* envAsNumber(undefined, 42) // 42 (legacy positional default)
223276
* ```
224277
*/
225278
/*@__NO_SIDE_EFFECTS__*/
226-
export function envAsNumber(value: unknown, defaultValue = 0): number {
227-
const numOrNaN = NumberParseInt(String(value), 10)
279+
export function envAsNumber(
280+
value: unknown,
281+
defaultValueOrOptions: number | EnvAsNumberOptions | undefined = 0,
282+
): number {
283+
const opts: EnvAsNumberOptions =
284+
typeof defaultValueOrOptions === 'number'
285+
? { defaultValue: defaultValueOrOptions }
286+
: (defaultValueOrOptions ?? {})
287+
const { allowInfinity = false, defaultValue = 0, mode = 'int' } = opts
288+
289+
// Fast-paths for the strict `string | undefined` shape (helpers semantics).
290+
if (value === undefined || value === null) {
291+
return defaultValue
292+
}
293+
if (typeof value === 'string') {
294+
if (!value) {
295+
return defaultValue
296+
}
297+
const num = mode === 'float' ? NumberCtor(value) : NumberParseInt(value, 10)
298+
if (NumberIsNaN(num)) {
299+
return defaultValue
300+
}
301+
if (!NumberIsFinite(num)) {
302+
return allowInfinity ? num : defaultValue
303+
}
304+
return num || 0
305+
}
306+
307+
// Broad (unknown) path — coerce via String() then parse.
308+
const numOrNaN =
309+
mode === 'float'
310+
? NumberCtor(String(value))
311+
: NumberParseInt(String(value), 10)
228312
const numMayBeNegZero = NumberIsFinite(numOrNaN)
229313
? numOrNaN
230314
: NumberCtor(defaultValue)
@@ -233,30 +317,74 @@ export function envAsNumber(value: unknown, defaultValue = 0): number {
233317
}
234318

235319
/**
236-
* Convert an environment variable value to a trimmed string.
320+
* Options for `envAsString`.
321+
*/
322+
export interface EnvAsStringOptions {
323+
/** Default when value is null/undefined. @default '' */
324+
defaultValue?: string | undefined
325+
/**
326+
* Whether to trim whitespace from string values. `true` (default) trims.
327+
* Set `false` to preserve whitespace (helpers.envAsString semantics).
328+
* @default true
329+
*/
330+
trim?: boolean | undefined
331+
}
332+
333+
/**
334+
* Convert an environment variable value to a string.
335+
*
336+
* Back-compat overload: passing a bare string as the second argument is
337+
* equivalent to `{ defaultValue: S }`.
237338
*
238339
* @param value - The value to convert
239-
* @param defaultValue - Default when value is null/undefined (default: `''`)
240-
* @returns The trimmed string value, or the default value
340+
* @param defaultValueOrOptions - Default (string) or options object
341+
* @returns The string value, or the default value
241342
*
242343
* @example
243344
* ```typescript
244345
* import { envAsString } from '@socketsecurity/lib/env'
245346
*
246-
* envAsString(' hello ') // 'hello'
247-
* envAsString(undefined) // ''
248-
* envAsString(null, 'n/a') // 'n/a'
347+
* envAsString(' hello ') // 'hello' (trimmed)
348+
* envAsString(' hello ', { trim: false }) // ' hello '
349+
* envAsString(undefined) // ''
350+
* envAsString(null, 'n/a') // 'n/a' (legacy positional)
249351
* ```
250352
*/
251353
/*@__NO_SIDE_EFFECTS__*/
252-
export function envAsString(value: unknown, defaultValue = ''): string {
253-
if (typeof value === 'string') {
254-
return value.trim()
354+
export function envAsString(
355+
value: unknown,
356+
defaultValueOrOptions: string | EnvAsStringOptions | undefined = '',
357+
): string {
358+
// Accept bare string OR any non-options value as positional default for
359+
// legacy compat (`envAsString(null, 123)` coerces to '123'). Options form
360+
// is detected by plain-object shape with known keys.
361+
const isOptionsObject =
362+
typeof defaultValueOrOptions === 'object' &&
363+
defaultValueOrOptions !== null &&
364+
!Array.isArray(defaultValueOrOptions) &&
365+
('defaultValue' in defaultValueOrOptions || 'trim' in defaultValueOrOptions)
366+
const opts: EnvAsStringOptions = isOptionsObject
367+
? (defaultValueOrOptions as EnvAsStringOptions)
368+
: {
369+
defaultValue:
370+
defaultValueOrOptions === undefined
371+
? ''
372+
: typeof defaultValueOrOptions === 'string'
373+
? defaultValueOrOptions
374+
: StringCtor(defaultValueOrOptions),
375+
}
376+
const { defaultValue = '', trim = true } = opts
377+
378+
if (value === undefined || value === null) {
379+
return defaultValue === '' || !trim
380+
? defaultValue
381+
: StringCtor(defaultValue).trim()
255382
}
256-
if (value === null || value === undefined) {
257-
return defaultValue === '' ? defaultValue : StringCtor(defaultValue).trim()
383+
if (typeof value === 'string') {
384+
return trim ? value.trim() : value
258385
}
259-
return StringCtor(value).trim()
386+
const str = StringCtor(value)
387+
return trim ? str.trim() : str
260388
}
261389

262390
/**

src/env/helpers.ts

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
/**
22
* @fileoverview Environment variable type conversion helpers.
33
*
4-
* NOTE: These helpers accept `string | undefined` and are designed for reading
5-
* process.env values directly. They differ from the `envAsBoolean`/`envAsNumber`/
6-
* `envAsString` exports in `@socketsecurity/lib/env`:
7-
*
8-
* - `envAsBoolean` here accepts `'yes'` as a truthy value (in addition to `'1'`
9-
* / `'true'`). The root export also accepts `'yes'` (unified) but takes
10-
* `unknown` and supports a configurable default.
11-
* - `envAsNumber` here uses `Number()` which preserves decimals; the root
12-
* export uses `parseInt(_, 10)` and returns integers only.
13-
* - `envAsString` here preserves whitespace; the root export trims.
14-
*
15-
* Internal env/*.ts modules import from this file for the raw env-string
16-
* semantics; external callers preferring integer/trimmed behavior should
17-
* import from `@socketsecurity/lib/env`.
4+
* Thin wrappers over the unified implementations in `@socketsecurity/lib/env`
5+
* that preserve the narrower `string | undefined` input signature and the
6+
* original strict-no-trim / float / whitespace-preserving defaults. Prefer
7+
* the root `env` module for new code — it supports both modes via options.
188
*/
199

10+
import {
11+
envAsBoolean as envAsBooleanRoot,
12+
envAsNumber as envAsNumberRoot,
13+
envAsString as envAsStringRoot,
14+
} from '../env'
15+
2016
/**
2117
* Convert an environment variable string to a boolean.
18+
* Strict matching — does NOT trim whitespace (' true ' is false).
2219
*
2320
* @param value - The environment variable value to convert
24-
* @returns `true` if value is 'true', '1', or 'yes' (case-insensitive), `false` otherwise
21+
* @returns `true` if value is exactly 'true', '1', or 'yes' (case-insensitive), `false` otherwise
2522
*
2623
* @example
2724
* ```typescript
@@ -30,47 +27,44 @@
3027
* envAsBoolean('true') // true
3128
* envAsBoolean('1') // true
3229
* envAsBoolean('yes') // true
30+
* envAsBoolean(' true ') // false (no trim)
3331
* envAsBoolean(undefined) // false
3432
* ```
3533
*/
3634
/*@__NO_SIDE_EFFECTS__*/
3735
export function envAsBoolean(value: string | undefined): boolean {
38-
if (!value) {
39-
return false
40-
}
41-
const lower = value.toLowerCase()
42-
return lower === 'true' || lower === '1' || lower === 'yes'
36+
return envAsBooleanRoot(value, { trim: false })
4337
}
4438

4539
/**
4640
* Convert an environment variable string to a number.
47-
* Uses `Number()` so decimal values are preserved; returns 0 for undefined or
48-
* NaN. For integer-only parsing see `envAsNumber` in `@socketsecurity/lib/env`.
41+
* Uses `Number()` (decimals, hex, octal, binary, Infinity preserved); returns
42+
* 0 only for undefined/empty/NaN. For int-only parsing use `envAsNumber` in
43+
* `@socketsecurity/lib/env` with default `mode: 'int'`.
4944
*
5045
* @param value - The environment variable value to convert
51-
* @returns The parsed number, or `0` if the value is undefined or not a valid number
46+
* @returns The parsed number, or `0` if the value is undefined or NaN
5247
*
5348
* @example
5449
* ```typescript
5550
* import { envAsNumber } from '@socketsecurity/lib/env/helpers'
5651
*
57-
* envAsNumber('3000') // 3000
58-
* envAsNumber(undefined) // 0
59-
* envAsNumber('abc') // 0
52+
* envAsNumber('3000') // 3000
53+
* envAsNumber('3.14') // 3.14
54+
* envAsNumber('Infinity') // Infinity
55+
* envAsNumber(undefined) // 0
56+
* envAsNumber('abc') // 0
6057
* ```
6158
*/
6259
/*@__NO_SIDE_EFFECTS__*/
6360
export function envAsNumber(value: string | undefined): number {
64-
if (!value) {
65-
return 0
66-
}
67-
const num = Number(value)
68-
return Number.isNaN(num) ? 0 : num
61+
return envAsNumberRoot(value, { mode: 'float', allowInfinity: true })
6962
}
7063

7164
/**
7265
* Convert an environment variable value to a string, preserving whitespace.
73-
* For trimmed-string behavior, see `envAsString` in `@socketsecurity/lib/env`.
66+
* For trimmed-string behavior use `envAsString` in `@socketsecurity/lib/env`
67+
* (default `trim: true`); this helper passes `trim: false`.
7468
*
7569
* @param value - The environment variable value to convert
7670
* @returns The string value, or an empty string if undefined
@@ -80,10 +74,11 @@ export function envAsNumber(value: string | undefined): number {
8074
* import { envAsString } from '@socketsecurity/lib/env/helpers'
8175
*
8276
* envAsString('hello') // 'hello'
77+
* envAsString(' x ') // ' x ' (whitespace preserved)
8378
* envAsString(undefined) // ''
8479
* ```
8580
*/
8681
/*@__NO_SIDE_EFFECTS__*/
8782
export function envAsString(value: string | undefined): string {
88-
return value || ''
83+
return envAsStringRoot(value, { trim: false })
8984
}

0 commit comments

Comments
 (0)