npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

konfeeg

v0.0.14

Published

Build a validated, strongly-typed config object, and use it everywhere.

Readme

konfeeg

Validated, strongly-typed, multi-environment config for Node and the browser. Define a schema once; values are resolved, coerced, and validated at startup — missing or invalid values throw immediately.

Features

| Feature | konfeeg | [convict] | [node-config] | | ----------------------------- | ------- | --------- | ------------- | | Browser support | ✅ | ❌ | ❌ | | First-class multi-env support | ✅ | ❌ | ❌ | | Runtime env var overrides | ✅ | ✅ | ✅ | | Strongly typed | ✅ | ✅ | ❌ | | Supports import.meta.env | ✅ | ❌ | ❌ | | Runtime validation | ✅ | ✅ | ❌ | | Conditional / variant config | ✅ | ❌ | ❌ | | Custom validations | ❌ | ✅ | ❌ |

[convict]: https://github.com/mozilla/node-convict/tree/master/packages/convict [node-config]: https://github.com/node-config/node-config


Quick start

import { createEnvironmentConfig } from "konfeeg";

// 1. Declare the names of your environments
type MyEnvs = {
  dev?: unknown; // optional (?) = per-env value may be omitted
  staging: unknown; // required = must supply a value
  production: unknown;
};

// 2. Build the config —- note the extra (), it's curried for TS inference
const config = createEnvironmentConfig<MyEnvs>()("staging", {
  // ← this env name should be dynamic in a real app
  apiUrl: {
    doc: "Base URL for the API",
    format: "url", // Will error if the value isn't a valid URL
    processEnv: "API_URL", // runtime override (highest priority)
    dev: "http://localhost:3000",
    staging: "https://staging-api.example.com",
    production: "https://api.example.com",
  },
  logLevel: {
    doc: "Minimum log level",
    format: ["debug", "info", "warn", "error"] as const, // Must be one of these literals
    processEnv: "LOG_LEVEL",
    dev: "debug",
    staging: "info",
    production: "warn",
  },
  port: {
    doc: "HTTP port to listen on",
    format: Number, // Numeric strings (e.g. from env vars) are coerced
    processEnv: "PORT",
    value: 3000,
  },
  allowedOrigins: {
    doc: "CORS allow-list",
    format: Array, // Value must be an array
    staging: ["https://staging.example.com"],
    production: ["https://example.com", "https://admin.example.com"],
  },
  mongo: {
    dbName: {
      doc: "Mongo database name",
      format: String, // Will error if the value isn't a string
      processEnv: "MONGO_DB_NAME",
      value: "my-app-db", // static fallback (lowest priority)
    },
    poolSize: {
      doc: "Max connections in the Mongo pool",
      format: Number,
      optional: true, // missing value resolves to `default` instead of throwing
      default: 10,
    },
  },
});

config.env; // "staging"
config.apiUrl; // string (validated as URL)
config.logLevel; // "debug" | "info" | "warn" | "error"
config.port; // number
config.allowedOrigins; // string[]
config.mongo.dbName; // string
config.mongo.poolSize; // number

[!important] The above example hardcodes the active environment ("staging") for clarity, but you'll want that to be dynamic in a real app:

type WhichEnvsAreRequired = {
  local?: unknown // optional (?) = per-env value may be omitted
  nonprod: unknown // required = must supply a value
  prod: unknown
}

// type AppEnvironment = keyof WhichEnvsAreRequired // "local" | "nonprod" | "prod"

const appEnv = import.meta.env.VITE_APP_ENV as AppEnvironment
if (!appEnv) throw new Error("VITE_APP_ENV is required")

const config = createEnvironmentConfig<WhichEnvsAreRequired>()(appEnv, { ... })

Schema fields

| Field | Required | Description | | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | doc | required | Human-readable description | | format | optional | Validation format — see below. If omitted, no validation is applied; the resolved type is inferred from value/per-env fields, or any if none are declared | | value | optional | Constant shared across all environments (lowest priority) | | processEnv | optional | process.env key — runtime override (highest priority) | | importMetaEnv | optional | Browser environment key — runtime override (highest priority), optionally supplied through options.importMetaEnv; see browser environments | | optional | optional | When true, missing value will not throw. Resolves to default field or undefined | | default | optional | Fallback to replace undefined when optional: true and no value is found | | env keys (e.g. dev, staging…) | optional | Per-environment value overrides. (These are the env names that you pass in in) |


Formats

Validate that the resolved value matches the declared format. Coercion is applied where reasonable (e.g. numeric strings → numbers, 'true'/'false' → booleans).

| Format | Resolved type | Notes | | ------------ | ------------- | ------------------------------------------ | | String | string | Value must be a string | | Number | number | Numeric strings are coerced | | Boolean | boolean | 'true'/'false' and 0/1 are coerced | | Array | any[] | Value must be an array | | 'url' | string | Must parse as a valid URL | | ['a', 'b'] | 'a' \| 'b' | Value must be one of the listed literals |


Value resolution order

When multiple sources are declared on the same entry, the highest-priority source wins.

| Priority | Source | Use for | | ----------- | ---------------------------------- | -------------------------------- | | 3 — highest | processEnv / importMetaEnv | Secrets, local overrides | | 2 | Per-env fields (dev, staging…) | Environment-specific values | | 1 — lowest | value | Constants shared across all envs |


Browser environments and CommonJS/CI

Vite automatically selects konfeeg's browser entry point, which reads import.meta.env. No extra option or different import is needed:

const config = createEnvironmentConfig<MyEnvs>()(
  "staging",
  {
    apiUrl: {
      doc: "API URL",
      format: "url",
      importMetaEnv: "VITE_API_URL",
      value: "https://api.example.com",
    },
  },
);

For tests or other runtime sources, optionally pass a map through the options argument:

const config = createConfig(env, schema, {
  importMetaEnv: { VITE_API_URL: "https://test.example.com" },
});

// Disable browser overrides and use the schema's configured values.
const defaults = createConfig(env, schema, { importMetaEnv: {} });

An explicit map replaces the browser source rather than merging with it. Runtime values have the highest precedence and undergo normal validation and coercion. If a key is missing or undefined, resolution uses configured per-environment values, fallbacks, static value, or optional default. Required values still throw if no source resolves.

Only the browser ESM entry point references import.meta. Node imports and CommonJS consumers use the universal artifacts, which do not contain it. Node-targeted esbuild bundles (including CDK Lambda assets) therefore do not need a special option to avoid empty-import-meta warnings. Two-argument backend calls and processEnv behavior are unchanged.

Automatic lookup requires a bundler that selects the browser export condition for ESM imports and supplies import.meta.env, such as Vite. Without that support (including native browser modules), provide the map explicitly or rely on configured values. CommonJS require always uses the universal entry point, even when the browser condition is enabled.


Fallbacks

When an entry has no value for the active environment, resolution can fall back to another env's value. Chains are transitive. Only affects per-env resolution (priority 2) — runtime env vars still win.

const config = createEnvironmentConfig<MyEnvs>()(
  "dev",
  {
    apiUrl: {
      doc: "API URL",
      format: "url",
      // no `dev` field — falls back to `integ`
      integ: "https://integ.example.com",
      staging: "https://staging.example.com",
      production: "https://api.example.com",
    },
  },
  {
    fallbacks: {
      dev: "integ", // dev → integ
      integ: "staging", // integ → staging (chains with prev. fallback is now dev → integ → staging)
    },
  },
);

config.apiUrl; // "https://integ.example.com"

A circular fallback chain (e.g. { dev: 'integ', integ: 'dev' }) throws synchronously with the cycle path in the error message.


Variant groups (conditional config)

Sometimes which fields are required depends on the value of another field. A variant group models this as a discriminated union: an enum entry (the discriminant) selects which sub-group of fields is resolved. Only the selected variant's fields are resolved and required — the others are never read.

A variant group is any node with a variants map (including the root config object itself). Inside the map, the discriminant is its only direct entry — everything else is a group holding one variant's fields. The groups themselves do not exist in the final config, only the contents of the selected variant group are resolved.

So the structure is:

// This can exist at the root of the config or at any nested level.
{
  variants: {
    myVariantKey: { doc: "...", format: ["foo", "bar"] as const },
    foo: { // <-- group for one of the myVariantKey strings (does not exist in final config)
       fooField1: { doc: "...", format: String },
       fooField2: { doc: "...", format: Number },
     },
    bar: { // <-- group for one of the myVariantKey strings (does not exist in final config)
      barField1: { doc: "...", format: Boolean },
      barField2: { doc: "...", format: Array },
     },
  }
}

Here's a complete example of a variant group at the root of the config:

const config = createEnvironmentConfig<MyEnvs>()("production", {
  // Shared field — a sibling of `variants`, resolved for every variant.
  poolSize: { doc: "Max pool size", format: Number, value: 10 },
  variants: {
    // The discriminant. Its key ("driver") is the output property name.
    driver: {
      doc: "Database driver",
      format: ["pg", "awsDataApi"] as const,
      processEnv: "DB_DRIVER",
      value: "pg", // default driver (a runtime DB_DRIVER wins)
    },
    pg: {
      connectionString: {
        doc: "PG URL",
        format: String,
        processEnv: "DATABASE_URL",
      },
    },
    awsDataApi: {
      resourceArn: {
        doc: "Resource ARN",
        format: String,
        processEnv: "DB_RESOURCE_ARN",
      },
      secretArn: {
        doc: "Secret ARN",
        format: String,
        processEnv: "DB_SECRET_ARN",
      },
      database: { doc: "DB name", format: String, processEnv: "DB_NAME" },
    },
  },
});

// The whole `config` is a discriminated union — narrow it with the discriminant:
if (config.driver === "pg") {
  config.connectionString; // string
} else {
  config.resourceArn; // string
}
config.poolSize; // number — a shared field, available on every variant

Resolves to:

// `config` — each member also carries `env: "dev" | "staging" | "production"`
| { driver: "pg"; poolSize: number; connectionString: string }
| { driver: "awsDataApi"; poolSize: number; resourceArn: string; secretArn: string; database: string }
  • The discriminant is a normal enum entry, so it supports the full value resolution order (value, per-env fields, processEnv/importMetaEnv) and fallbacks. It's distinguished from the variant groups around it by being an entry (it has a doc).
  • Selecting one variant does not require the other variants' sources.
  • If the discriminant resolves to a value with no matching variant, resolution throws and lists the valid variant keys.
  • Variant groups work anywhere a regular entry or group can — at the root of the config (as above), inside a group, or inside another variant.
  • Shared fields (siblings of variants) resolve flat alongside the selected variant's fields. A shared field whose key collides with the discriminant key or with a field of the selected variant is ambiguous and throws.

[!note] variants is a reserved key: a group containing a variants child is always treated as a variant group.


defineEnvironmentConfig

Same as createEnvironmentConfig, but binds the schema first and the environment later — useful when the environment isn't known at schema-definition time.

import { defineEnvironmentConfig } from "konfeeg";

const buildConfig = defineEnvironmentConfig<MyEnvs>()({/* schema */});

const config = buildConfig(process.env.APP_ENV as any);

Building your own wrapper

If you need to create several configs in your project that all use the same environment names, you can wrap createEnvironmentConfig in a function that pre-defines your org's env names and env-resolution logic.

When creating a wrapper, in order to keep strong typing and avoid type casts, a functionally equivalent createUncheckedEnvironmentConfig function is provided to keep TypeScript happy.

import {
  createUncheckedEnvironmentConfig,
  type ConfigGroup,
  type ResolveTopLevelConfig,
  type ValidateSchema,
} from "konfeeg";

type MyCompanyAppEnvs = {
  local?: unknown;
  staging: unknown;
  production: unknown;
};

export type MyCompanyAppEnvironment = keyof MyCompanyAppEnvs; // "local" | "staging" | "production"

function resolveMyCompanyAppEnvironment(): MyCompanyAppEnvironment {
  const env = process.env.APP_ENV;
  if (!env) throw new Error("APP_ENV is required");
  return env as MyCompanyAppEnvironment;
}

// Bind the envs once; this plain-`G` builder is the forwarding target.
const buildConfig = createUncheckedEnvironmentConfig<MyCompanyAppEnvs>();

export function createMyCompanyAppConfig<
  const G extends ConfigGroup<MyCompanyAppEnvs>,
>(
  schema: G & ValidateSchema<G, MyCompanyAppEnvs>,
): ResolveTopLevelConfig<G> & { env: MyCompanyAppEnvironment } {
  return buildConfig(resolveMyCompanyAppEnvironment(), schema);
}

This composes to any depth, a second generic forwarder (e.g. createLambdaConfig) can declare its own G & ValidateSchema<G, E> parameter and forward into the same sink, staying cast-free while validating enum literals at each boundary.

Consumers now declare only a schema; the env names and the active environment come from the wrapper:

import { createMyCompanyAppConfig } from "./app-config.js";

export const config = createMyCompanyAppConfig({
  logLevel: {
    doc: "Minimum log level",
    format: ["debug", "info", "warn"] as const,
    staging: "info",
    production: "warn",
  },
});

config.env; // "local" | "staging" | "production"
config.logLevel; // "debug" | "info" | "warn"

Inference is fully preserved through the wrapper: literal types, enum unions, variant groups, and schema errors all behave exactly as they do on a direct call, and a bad enum value is still reported on the offending key rather than smeared across its siblings.