Skip to content
Open
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
75 changes: 62 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ The recommended way to structure applications with this package is as follows:
'use strict'

const Config = require('@logdna/env-config')
const config = new Config([
Config.string('loglevel').default('info')
, Config.number('port').default(3000)
const config = Config.createConfig([
Config.string('loglevel').default('info'),
Config.number('port').default(3000),
])

module.exports = config
Expand All @@ -54,12 +54,50 @@ const config = require('./config.js')
// This validates that we have the necessary env vars.
config.validateEnvVars()

http.listen(config.get('port'), () => {
log.info('listen', config.get('port'))
// When typing `config.get(<name>)` you should see auto-complete
// for what's been configured in `config.js` and the variable
// assigned should have its type inferred; in this case a `number`.
const port = config.get('port') // inferred as number
// const port = config.port // alternatively, direct property access

http.listen(port, () => {
log.info('listen', port)
})
```

Under the hood, `Config` is a [`<Map>`][], so use it like one.
Under the hood, `Config` is a [`<Map>`][], so, for the most part, you can use it like one.

## Auto-complete

This package ships a `index.d.ts` file. Modern editors (VS Code, WebStorm, etc...)
automatically pick up these TypeScript declaration files and surface
**IntelliSense in plain `.js` files**. You do **not** need to convert your project
to TypeScript to enjoy auto-complete but you will need to use `createConfig` instead
of using `new Config` constructor when no TypeScript tooling is present or used by
your editor.

### Direct Property Access

At runtime each definition name is exposed as an enumerable getter on the `Config` instance.
This means in a Node REPL you can do the following:

```
> const Config = require('@logdna/env-config')
> const cfg = new Config([ Config.string('name').default('app'), Config.number('port').default(3000) ])
> cfg.validateEnvVars()
> cfg. // press <TAB> shows: name, port, get, set, ...
```

The `.d.ts` declaration includes an index signature so modern editors, and TypeScript enabled
editors/tooling know those dynamic properties exist.

### Factory Helper `createConfig`

`createConfig([...])` is a convenience wrapper returning a typed `Config` instance; identical to `new Config([...])`
but can improve inference in some editor cases.

## Generating Docs


This package also provides a way to automatically generate documentation
for the environment variables for a service.
Expand Down Expand Up @@ -87,6 +125,16 @@ You should also add a link to this document in the `README.md` of the service.

Each `input` item should be a `Definition`. See "Static Methods" below.

### `Config.createConfig(input)`

* `input` [`<Array>`][] Array of objects that represent a single rule.

Each `input` item should be a `Definition`. See "Static Methods" below.

If you want auto-complete and type inference features, and are using an
editor which doesn't support or isn't configured to use typescript type
definitions, use this instead of `new Config(input)`.

### Static Methods
---

Expand Down Expand Up @@ -312,7 +360,7 @@ is a dead code path. These two options are mutually exclusive.
* `expected` [`<RegExp>`][] The regular expression that is expected to
match the discovered value
* `actual` *(Any)* The value that was discovered in the environment
* `env` [`<String>`][] The name of the evironment variable that is supposed
* `env` [`<String>`][] The name of the environment variable that is supposed
to hold the value (upper cased with underscores, e.g. `MY_VARIABLE`)

This error is thrown if [`Config.regex()`](#configregexname) was used,
Expand All @@ -324,7 +372,7 @@ but the discovered value in the environment did not match the pattern.
* `name` [`<String>`][] Static value of `EnumError`
* `expected` [`<Array>`][] The list of acceptable values for the definition
* `actual` *(Any)* The value that was discovered in the environment
* `env` [`<String>`][] The name of the evironment variable that is supposed
* `env` [`<String>`][] The name of the environment variable that is supposed
to hold the value (upper cased with underscores, e.g. `MY_VARIABLE`)

This error is thrown if [`Config.regex()`](#configregexname) was used,
Expand All @@ -339,7 +387,7 @@ but the discovered value in the environment did not match the pattern.
* `input` [`<String>`][] The the value of the environment variable after it was parsed and sanitized
* `original` [`<String>`][] The original value from the environment variable
* `type` [`<String>`][] The defined value type of the list property
* `env` [`<String>`][] The name of the evironment variable that is supposed
* `env` [`<String>`][] The name of the environment variable that is supposed
to hold the value (upper cased with underscores, e.g. `MY_VARIABLE`)

This error is thrown if [`Config.list()`](#configlistname) was used,
Expand All @@ -366,10 +414,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://evanlucas.com/"><img src="https://avatars.githubusercontent.com/u/677994?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Evan Lucas</b></sub></a><br /><a href="https://github.com/logdna/env-config-node/commits?author=evanlucas" title="Code">💻</a> <a href="https://github.com/logdna/env-config-node/commits?author=evanlucas" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/darinspivey"><img src="https://avatars.githubusercontent.com/u/1874788?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Darin Spivey</b></sub></a><br /><a href="https://github.com/logdna/env-config-node/commits?author=darinspivey" title="Code">💻</a> <a href="https://github.com/logdna/env-config-node/commits?author=darinspivey" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/jakedipity"><img src="https://avatars.githubusercontent.com/u/29671917?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jacob Hull</b></sub></a><br /><a href="#maintenance-jakedipity" title="Maintenance">🚧</a></td>
<td align="center"><a href="http://codedependant.net/"><img src="https://avatars.githubusercontent.com/u/148561?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Eric Satterwhite</b></sub></a><br /><a href="https://github.com/logdna/env-config-node/commits?author=esatterwhite" title="Code">💻</a></td>
<td align="center"><a href="https://evanlucas.com/"><img src="https://avatars.githubusercontent.com/u/677994?v=4&s=100" width="100px;" alt=""/><br /><sub><b>Evan Lucas</b></sub></a><br /><a href="https://github.com/logdna/env-config-node/commits?author=evanlucas" title="Code">💻</a> <a href="https://github.com/logdna/env-config-node/commits?author=evanlucas" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/darinspivey"><img src="https://avatars.githubusercontent.com/u/1874788?v=4&s=100" width="100px;" alt=""/><br /><sub><b>Darin Spivey</b></sub></a><br /><a href="https://github.com/logdna/env-config-node/commits?author=darinspivey" title="Code">💻</a> <a href="https://github.com/logdna/env-config-node/commits?author=darinspivey" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/jakedipity"><img src="https://avatars.githubusercontent.com/u/29671917?v=4&s=100" width="100px;" alt=""/><br /><sub><b>Jacob Hull</b></sub></a><br /><a href="#maintenance-jakedipity" title="Maintenance">🚧</a></td>
<td align="center"><a href="http://codedependant.net/"><img src="https://avatars.githubusercontent.com/u/148561?v=4&s=100" width="100px;" alt=""/><br /><sub><b>Eric Satterwhite</b></sub></a><br /><a href="https://github.com/logdna/env-config-node/commits?author=esatterwhite" title="Code">💻</a></td>
<td align="center"><a href="http://codedependant.net/"><img src="https://avatars.githubusercontent.com/u/4065262?v=4&s=100" width="100px;" alt=""/><br /><sub><b>Justin Gross</b></sub></a><br /><a href="https://github.com/logdna/env-config-node/commits?author=justintime4tea" title="Code">💻</a><a href="https://github.com/logdna/env-config-node/commits?author=justintime4tea" title="Documentation">📖</a></td>
</tr>
</table>

Expand Down
122 changes: 122 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Type definitions for @logdna/env-config
// Minimal, inferred key/value typing for configuration definitions

// Utility types to extract literal types from arrays
export type ReadonlyStringArray = readonly string[];

// Base definition interface (runtime class is untyped JS; this is a structural representation)
interface BaseDefinition<Name extends string = string, Kind extends string = string> {
_name: Name;
_type: Kind;
// Chainable common methods
required(): this;
desc(str: string): this;
description(str: string): this;
default(val: any): this; // default does not change exposed type here (runtime may still allow null)
allowEmpty(): this;
name(str: string): this;
}

// Enum extension captures allowed values to narrow type
interface EnumCapable<Name extends string = string> extends BaseDefinition<Name, 'enum'> {
values<V extends ReadonlyStringArray>(vals: V): EnumCapableWithValues<Name, V>;
}
interface EnumCapableWithValues<Name extends string, V extends ReadonlyStringArray> extends BaseDefinition<Name, 'enum'> {
readonly __enumValues: V[number];
values(vals: V): this; // further calls keep same type
}

// Regex definition
interface RegexDefinition<Name extends string = string> extends BaseDefinition<Name, 'regex'> {
match(re: string | RegExp): this;
}

// Number definition (min/max chaining)
interface NumberDefinition<Name extends string = string> extends BaseDefinition<Name, 'number'> {
min(n: number): this;
max(n: number): this;
}

// Boolean definition (no special methods beyond base)
interface BooleanDefinition<Name extends string = string> extends BaseDefinition<Name, 'boolean'> {}

// String definition
interface StringDefinition<Name extends string = string> extends BaseDefinition<Name, 'string'> {}

// List definition (captures element type & separator)
interface ListDefinition<Name extends string = string, ElemKind extends ListElementKind | undefined = undefined> extends BaseDefinition<Name, 'list'> {
type<T extends ListElementKind>(t: T): ListDefinitionWithType<Name, T>;
separator(val: string | RegExp): this;
}
interface ListDefinitionWithType<Name extends string, ElemKind extends ListElementKind> extends BaseDefinition<Name, 'list'> {
readonly __listType: ElemKind;
type(t: ElemKind): this; // idempotent if called again
separator(val: string | RegExp): this;
}

type ListElementKind = 'string' | 'number' | 'boolean';

// Aggregate union of any definition forms
export type DefinitionAny =
| StringDefinition<string>
| NumberDefinition<string>
| BooleanDefinition<string>
| RegexDefinition<string>
| EnumCapable<string>
| EnumCapableWithValues<string, ReadonlyStringArray>
| ListDefinition<string>
| ListDefinitionWithType<string, ListElementKind>;

// Infer the names from a readonly tuple of definitions
export type DefinitionNames<Defs extends readonly DefinitionAny[]> = Defs[number]['_name'];

// Find definition by name in tuple
export type FindDefinition<Defs extends readonly DefinitionAny[], N extends string> = Extract<Defs[number], { _name: N }>;

// Map definition kind (and its refinements) to a resulting value type.
// These reflect runtime behavior loosely (null for unset strings/lists, undefined for invalid booleans, etc.)
export type ValueOfDefinition<D> =
D extends EnumCapableWithValues<any, any> ? D['__enumValues'] | null :
D extends { _type: 'enum' } ? string | null :
D extends { _type: 'string' } ? string | null :
D extends { _type: 'number' } ? number :
D extends { _type: 'boolean' } ? boolean | undefined :
D extends { _type: 'regex' } ? string | null :
D extends ListDefinitionWithType<any, infer E> ? (
E extends 'string' ? string[] | null :
E extends 'number' ? number[] | null :
E extends 'boolean' ? (boolean | undefined)[] | null : any
) :
D extends { _type: 'list' } ? any[] | null :
unknown;

// Produce a record type for all definitions in a tuple
export type ConfigShape<Defs extends readonly DefinitionAny[]> = {
[K in DefinitionNames<Defs>]: ValueOfDefinition<FindDefinition<Defs, K>>
};

// Main Config class declaration
declare class Config<Defs extends readonly DefinitionAny[] = DefinitionAny[]> extends Map<DefinitionNames<Defs>, any> {
constructor(input: Defs);
// Override get/has with key autocomplete and value typing
get<K extends DefinitionNames<Defs>>(key: K): ConfigShape<Defs>[K];
has<K extends DefinitionNames<Defs>>(key: K): boolean;
toJSON(): ConfigShape<Defs>;
validateEnvVars(): void;
// Index signature for direct property access (REPL getter injection at runtime)
[K in DefinitionNames<Defs>]: ConfigShape<Defs>[K];
// Static builders (preserve name literal type)
static string<N extends string>(name: N): StringDefinition<N>;
static number<N extends string>(name: N): NumberDefinition<N>;
static boolean<N extends string>(name: N): BooleanDefinition<N>;
static regex<N extends string>(name: N): RegexDefinition<N>;
static enum<N extends string>(name: N): EnumCapable<N>;
static list<N extends string>(name: N): ListDefinition<N>;
// Factory helper for JS users
static createConfig<D extends readonly DefinitionAny[]>(defs: D): Config<D>;
}

// Standalone factory function mirroring the static (CommonJS export augmentation)
export function createConfig<D extends readonly DefinitionAny[]>(defs: D): Config<D>;

export = Config;
49 changes: 48 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use strict'

const util = require('util')
const Definition = require('./lib/definition.js')

module.exports = class Env extends Map {
// Exported below; defined as a named class so we can augment prototype safely
class Config extends Map {
constructor(input) {
super()

Expand Down Expand Up @@ -57,6 +59,8 @@ module.exports = class Env extends Map {
validateEnvVars() {
for (const rule of this.rules.values()) {
rule.validate()
// After validation, refresh the stored value in case rule mutated (_value)
this.set(rule._name, rule._value)
}
}

Expand All @@ -69,7 +73,50 @@ module.exports = class Env extends Map {

this.set(rule._name, rule._value)
this.rules.set(rule._name, rule)

// Expose each rule as an enumerable getter property for REPL / plain JS autocomplete
if (!Object.prototype.hasOwnProperty.call(this, rule._name)) {
Object.defineProperty(this, rule._name, {
enumerable: true
, configurable: true
, get: function() {
return this.get(rule._name)
}
})
}
return this
}
}

// Friendly REPL / console inspection (shows plain object of key/value pairs)
Config.prototype[util.inspect.custom] = function() {
return this.toJSON()
}

/**
* NOTE: The TypeScript declaration file (index.d.ts) supplies the rich generic
* types. These JSDoc typedefs and helper simply help JS-only consumers get autocomplete
* without enabling TypeScript compilation.
*/

/**
* Factory helper for JS users wanting stronger inference through JSDoc.
*
* The TypeScript declaration file (index.d.ts) supplies rich generic types.
* These JSDoc typedefs and helper simply help JS-only consumers get autocomplete
* without enabling TypeScript compilation.
*
* @template {readonly any[]} Defs
* @param {Defs} defs
* @returns {Config} (Generic mapping refined by the .d.ts file)
*/
function createConfig(defs) {
return new Config(defs)
}

// Attach factory to class & exports
Config.createConfig = createConfig

module.exports = Config
module.exports.createConfig = createConfig

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
"version": "2.0.1",
"description": "Configuration package for reading environment variables",
"main": "index.js",
"types": "index.d.ts",
"files": [
"bin/**/*",
"lib/**/*",
"index.js",
"index.d.ts",
"README.md",
"LICENSE"
],
Expand Down
Loading