@emartech/octopus-config-ts
v1.0.1
Published
Schema-validated environment variable and config loading for TypeScript and JavaScript, with type inference.
Maintainers
Readme
@emartech/octopus-config-ts
Schema-validated environment variable and config loading for TypeScript and JavaScript, with type inference.
- Type-safe: the returned config is inferred from your schemas.
- Aggregating: every invalid variable is collected and reported in one error, not one at a time.
Libraries like envalid or env-var validate the environment and hand back the parsed values. @emartech/octopus-config-ts also lets you derive the config itself — nested shape, defaults, derived values, and conditional variables — in one place, so what you get back is the config, not a typed view over process.env.
Basic usage
Describe your config inside loadConfig and each variable is parsed against a schema:
import { loadConfig, s } from "@emartech/octopus-config-ts";
const config = loadConfig((env) => ({
nodeEnv: env("NODE_ENV", s.enumOf(["production", "test", "dev"])),
apiKey: env("API_KEY", s.string()),
limit: env("LIMIT", s.integer().min(10).max(100).default(25)),
database: {
url: env("DB_URL", s.string().url()),
queryTimeout: env("DB_QUERY_TIMEOUT", s.integer().min(0).default(30_000)),
},
featureFlag: env("FEATURE_FLAG", s.list(s.string()).default([])),
}));
/*
const config: {
nodeEnv: "production" | "test" | "dev";
apiKey: string;
limit: number;
database: {
url: string;
queryTimeout: number;
};
featureFlag: string[];
}
*/If anything fails to parse, loadConfig throws a single EnvError listing every problem:
3 invalid variable(s):
- NODE_ENV must be one of: production, test, dev
- LIMIT must be <= 100, got 500
- DB_URL is required but is not setdefineConfig (optional)
For larger apps you may want the config definition to live in its own module. defineConfig is an identity helper that preserves the callback's type so loadConfig can still infer the config shape:
// config.ts
import { defineConfig, s } from "@emartech/octopus-config-ts";
export const define = defineConfig((env) => ({
nodeEnv: env("NODE_ENV", s.enumOf(["production", "test", "dev"])),
apiKey: env("API_KEY", s.string()),
//...
}));
// main.ts
import { loadConfig } from "@emartech/octopus-config-ts";
import { define } from "./config";
const config = loadConfig(define);Because defineConfig returns the callback itself, a definition can be called
with env from inside another one, so config modules compose:
import { defineConfig, s } from "@emartech/octopus-config-ts";
const databaseConfig = defineConfig((env) => ({
url: env("DB_URL", s.string().url(["postgres"])),
queryTimeout: env("DB_QUERY_TIMEOUT", s.integer().min(0).default(30_000)),
//...
}));
const mainConfig = defineConfig((env) => ({
nodeEnv: env("NODE_ENV", s.enumOf(["production", "test", "dev"])),
db: databaseConfig(env),
//...
}));
const config = loadConfig(mainConfig);You never need defineConfig when a single definition is inline. It exists to
keep the callback typed when it's declared apart from its caller, and to let
definitions compose.
Reading from a custom source
loadConfig reads from process.env by default. Pass a second argument to read from anywhere, useful in tests:
const ENV = {
NODE_ENV: "test",
API_KEY: "secret-key",
//...
};
loadConfig(define, ENV);loadConfig reads process.env, it does not populate it. Getting variables into process.env is the caller's job: this library does not read .env files or load dotenv. If you use a .env file, load it (e.g. import "dotenv/config") before calling loadConfig.
Schemas
The s namespace bundles all schema constructors. You can also import them individually (string, integer, float, boolean, enumOf, list).
Types
s.string(); // string
s.integer(); // number (integer)
s.float(); // number
s.boolean(); // boolean
s.enumOf(["a", "b", "c"]); // "a" | "b" | "c"
s.list(s.integer()); // number[]Refiners
Every schema can be chained with refiners. Refiners narrow the accepted values: they run after parsing succeeded, and reject values that would otherwise be well-typed.
Available refiners by type:
| Schema | Refiners |
| ------------------ | --------------------------------------------------------------------- |
| all | .refine(predicate, message) |
| string | .minCharacters, .maxCharacters, .characters, .matches, .url |
| integer, float | .min, .max |
| integer | .minDigits, .maxDigits, .digits, .port |
| list | .minEntries, .maxEntries, .entries, .unique |
They compose freely:
s.string().minCharacters(1).maxCharacters(64);
s.integer().min(0).max(100);
s.integer().port();
s.string().url();
s.string().matches(/^[a-z0-9-]+$/);
s.list(s.string()).minEntries(1).unique();
s.string().refine((v) => v.startsWith("sk-"), "must start with sk-");Terminals
By default a variable is required. .optional() and .default(...) offer different ways to handle an unset variable. They change the schema's output type, and don't return a chainable schema.
.optional() widens the output to T | undefined — an unset variable produces undefined instead of a "required" error. When the variable is set, parsing and refiners run as usual:
s.string().optional(); // string | undefined.default(value) substitutes a fallback when the variable is unset, so the output stays T. value is the parsed type, not a raw string:
s.integer().min(0).default(3000); // number, always defined.default(value) runs the refiners against the fallback when the schema is constructed and throws if the fallback wouldn't pass, so you cannot ship a default that violates its own schema.
Parser options
Each schema constructor accepts an options object that controls how the raw
input is read. Schemas that take an argument
(s.enumOf, s.list) accept it as the second parameter:
s.integer({ strict: false });
s.enumOf(["dev", "prod"], { caseSensitive: false });
s.list(s.string(), { separator: ";" });Shared by all schemas:
trimInput(defaulttrue): strip whitespace around the raw input.emptyAsUndefined(defaulttrue): treat an empty (post-trim) value as unset, so it routes through.optional()/.default()/ the required-error path.
s.integer(), s.float():
strict(defaulttrue): require a plain decimal literal. Whenfalse, falls back toNumber(raw)(accepts"0x10","1e5","+5", etc.). Values must still be finite / integers.
s.boolean():
caseSensitive(defaultfalse).truthy/falsy(defaults["1", "true", "yes", "on"]and["0", "false", "no", "off"]): the values parsed astrueandfalse. Each replaces its default set rather than extending it.
s.enumOf():
caseSensitive(defaulttrue): whenfalse, matches case-insensitively and returns the canonical casing from the values list.
s.list():
separator(default",").trimEntries(defaulttrue): trim each entry after splitting.filterEmptyEntries(defaulttrue): drop empty entries (post-trim).
Advanced usage
Why values are Env<T> inside define
Inside the define callback, env(...) does not return the parsed value
directly — it returns an Env<T> wrapper:
loadConfig((env) => {
const nodeEnv = env("NODE_ENV", s.enumOf(["production", "test", "dev"]));
// Env<"production" | "test" | "dev">, not the union itself
return { nodeEnv };
});The callback runs before any variable is known to be readable. NODE_ENV may
be missing, or present but unparseable, so at the moment env("NODE_ENV", ...)
returns there may be no value to hand back. Env<T> stands in for that value:
it lets you describe what the config should do with the variable without
requiring the variable to exist yet.
This is what makes error collection possible. A failed read produces a failed
Env, and derivations over it are skipped rather than executed, so the
callback always runs to completion — every variable is read, and loadConfig
reports every failure at once instead of stopping at the first.
The Env wrapper never reaches your application. The tree returned from
define may contain Env<T> values nested in objects and arrays;
loadConfig unwraps them, stripping the wrapper from the return type.
To derive something from a variable, use one of the combinators below.
.map, .match, .chain
.map(fn) // Env<T> → Env<U>, transforms the value
.match(cases, ?fallback) // Env<K> → Env<U>, switch on a literal value
.chain(fn) // Env<T> → Env<U>, where fn returns another Env.map for plain transformations:
const nodeEnv = env("NODE_ENV", s.enumOf(["production", "test", "dev"]));
const cookieSecure = nodeEnv.map((v) => v === "production");Note that cookieSecure derives from the existing nodeEnv binding rather
than reading NODE_ENV again. Each name may only be read once inside the
callback — a second read throws an EnvUsageError, which also rules out
parsing the same variable under two different schemas.
// ❌ NODE_ENV is read twice (and with different schemas)
loadConfig((env) => ({
nodeEnv: env("NODE_ENV", s.enumOf(["production", "test", "dev"])),
cookieSecure: env("NODE_ENV", s.string()).map((v) => v === "production"),
}));
// ✅ bind once, derive from the binding
loadConfig((env) => {
const nodeEnv = env("NODE_ENV", s.enumOf(["production", "test", "dev"]));
return {
nodeEnv,
cookieSecure: nodeEnv.map((v) => v === "production"),
};
});The restriction is on the read, not on the value — a single binding can feed any number of derivations.
Don't return an Env from .map — it throws an EnvUsageError. Use
.match or .chain for that.
.match
For branching on a value. .match is exhaustive, TypeScript requires a case for
every member or a fallback as the second argument. With s.string() the set
of values is open, so a fallback is always required.
Cases may return plain values or Env values, and only the selected case
runs.
const storage = env("STORAGE", s.enumOf(["s3", "disk"])).match({
s3: () => env("S3_BUCKET", s.string()),
disk: () => env("DISK_PATH", s.string()),
});.chain
The general form of .match. So anything .match expresses, .chain can too, just more verbosely.
Reach for .chain only when the branch isn't a string dispatch: a boolean, a predicate, a comparison, or a discriminant nested inside an object. The callback must return an Env, so plain-value branches need env.const.
const dbUrl = env("USE_TEST_DB", s.boolean()).chain((useTest) =>
useTest ? env("DB_URL_TEST", s.string()) : env("DB_URL", s.string()),
);env.group
Read several variables that share a schema into a labelled object. Useful for feature toggles keyed by customer, where each variable holds the same shape (a list of customer IDs) and only the name changes:
const customerIdList = s.list(s.integer()).default([]);
const config = loadConfig((env) => ({
betaFeatures: env.group(customerIdList, {
newDashboard: "BETA_NEW_DASHBOARD_CUSTOMERS",
fastCheckout: "BETA_FAST_CHECKOUT_CUSTOMERS",
aiSuggestions: "BETA_AI_SUGGESTIONS_CUSTOMERS",
}),
}));
// config.betaFeatures: {
// newDashboard: number[],
// fastCheckout: number[],
// aiSuggestions: number[],
// }Defining the schema on its own line keeps the group readable and lets you reuse it. Every value in the group must share the schema, so pulling it out makes that explicit.
env.prefixed
Read a block of variables that share a common name prefix. Inside the callback, env behaves normally except every name is prefixed:
const config = loadConfig((env) => ({
db: env.prefixed("DB_", (env) => ({
host: env("HOST", s.string()), // reads DB_HOST
port: env("PORT", s.integer()), // reads DB_PORT
name: env("NAME", s.string()), // reads DB_NAME
})),
}));Prefixes nest: a prefixed inside a prefixed concatenates:
env.prefixed("DB_", (env) =>
env.prefixed("PRIMARY_", (env) => ({
host: env("HOST", s.string()), // reads DB_PRIMARY_HOST
})),
);env.const
Lift a plain value into an Env.
const lifted = env.const(123); // Env<number>env.all and env.struct
Combine several Env values into one. env.all into a tuple, env.struct
into an object:
const config = loadConfig((env) => ({
db: {
url: env
.all(
env("DB_HOST", s.string()),
env("DB_PORT", s.integer().port()),
env("DB_NAME", s.string()),
)
.map(([host, port, name]) => `postgres://${host}:${port}/${name}`),
},
}));Use env.all when the inputs collapse into a single value, and env.struct
when you want to keep them as named fields:
const redis = env.struct({
host: env("REDIS_HOST", s.string()),
port: env("REDIS_PORT", s.integer().port()),
});
// Env<{ host: string; port: number }>Either way, every input is read even if an earlier one failed, so all failures are reported together — and the result fails as a unit, so the group is present in full or not at all.
Reusable config blocks
When the same shape appears more than once under different prefixes, combine defineConfig with env.prefixed to define the block once and stamp it out per instance.
A defineConfig callback takes env and returns config, which is exactly what env.prefixed expects — so you can pass one straight into the other:
const batchOptions = defineConfig((env) => ({
prefetchCount: env("PREFETCH_COUNT", s.integer().default(2048)),
batchSize: env("BATCH_SIZE", s.integer().default(1000)),
batchTimeout: env("BATCH_TIMEOUT", s.integer().default(30000)),
retryTime: env("RETRY_TIME", s.integer().default(5000)),
}));
const config = loadConfig((env) => ({
refundsBatchOptions: env.prefixed("REFUNDS_", batchOptions),
flexibleRefundsBatchOptions: env.prefixed("FLEXIBLE_REFUNDS_", batchOptions),
}));Each env.prefixed(..., batchOptions) reads REFUNDS_PREFETCH_COUNT, FLEXIBLE_REFUNDS_PREFETCH_COUNT, and so on. The block is defined once; only the prefix changes.
When instances need different defaults, wrap the definition in a plain function that takes overrides:
type BatchDefaults = Partial<{
prefetchCount: number;
batchSize: number;
batchTimeout: number;
retryTime: number;
}>;
const batchOptions = (defaults: BatchDefaults = {}) =>
defineConfig((env) => ({
prefetchCount: env(
"PREFETCH_COUNT",
s.integer().default(defaults.prefetchCount ?? 2048),
),
batchSize: env(
"BATCH_SIZE",
s.integer().default(defaults.batchSize ?? 1000),
),
batchTimeout: env(
"BATCH_TIMEOUT",
s.integer().default(defaults.batchTimeout ?? 30000),
),
retryTime: env(
"RETRY_TIME",
s.integer().default(defaults.retryTime ?? 5000),
),
}));
const config = loadConfig((env) => ({
refundsBatchOptions: env.prefixed("REFUNDS_", batchOptions()),
flexibleRefundsBatchOptions: env.prefixed(
"FLEXIBLE_REFUNDS_",
batchOptions(),
),
contactChangesBatchOptions: env.prefixed(
"CONTACT_CHANGES_",
batchOptions({ batchSize: 250 }),
),
}));Errors
loadConfig() throws two error classes:
EnvError — thrown by loadConfig when one or more variables failed to parse.
EnvUsageError — thrown when the library is misused, not when input is bad. Signals a bug in the calling code, not a config problem. Cases:
- Reading the same variable twice inside
define. - Calling
env(...)afterloadConfighas returned. - Returning an
Envfrom a.mapcallback (use.chain). - Calling
.matchwithout a case for the value and without a fallback.
