Type-safe configuration from environment variables and .env files, inspired by Starlette's config.
- Type-safe - Full TypeScript type inference from your config schema
- Simple API - Define your schema with field builders, get typed config object
- No dependencies - Zero runtime dependencies
- Type coercion - Automatic type conversion for strings, numbers, booleans, arrays, and JSON
- .env support - Reads from .env files with environment variable precedence
- Variable interpolation - Reference variables in .env files with
$VARor${VAR}syntax - Automatic case conversion - Use camelCase in your schema, automatically looks for SCREAMING_SNAKE_CASE env vars
- Ephemeral and derived fields - Intermediate values that don't pollute your config, and computed fields
- Environment wrapper - Prevents accidental modification of already-read environment variables
- Flexible - Supports required/optional fields, defaults, custom env var names, and custom coercion
This project is new, not yet production-ready, and may contain bugs, obvious problems, or missing features. Use at your own risk.
npm install configlette
# or
pnpm add configlette
# or
yarn add configletteimport {
load,
string,
number,
boolean,
array,
oneOf,
type InferConfig,
} from "configlette";
// Define your config schema with camelCase keys
const schema = {
databaseUrl: string(),
port: number().default(3000),
debug: boolean().default(false),
allowedOrigins: array(string()).default([]),
apiKey: string().optional(),
} as const;
// Load config from environment and .env file
// Automatically looks for DATABASE_URL, PORT, DEBUG, etc.
export const config = load(schema, {
envFile: ".env",
envPrefix: "APP_",
});
// Export the inferred type
export type AppConfig = InferConfig<typeof schema>;
// config is fully typed!
console.log(config.databaseUrl); // string
console.log(config.port); // number
console.log(config.debug); // boolean
console.log(config.allowedOrigins); // string[]
console.log(config.apiKey); // string | undefinedconst config = load(
{
DATABASE_URL: string(),
PORT: number().default(3000),
},
{ envPrefix: "MYAPP_" },
);
// Reads MYAPP_DATABASE_URL and MYAPP_PORTconst config = load({
databaseUrl: string().fromEnv("DATABASE_URL"),
apiKey: string().fromEnv("EXTERNAL_API_KEY"),
});import {
load,
string,
array,
json,
custom,
type InferConfig,
} from "configlette";
const schema = {
APP_NAME: string(),
ALLOWED_HOSTS: array(string()).default([]),
FEATURE_FLAGS: json<string[]>().optional(),
LOG_LEVEL: custom((s) => {
const level = s.toUpperCase();
if (!["DEBUG", "INFO", "WARN", "ERROR"].includes(level)) {
throw new Error("Invalid log level");
}
return level as "DEBUG" | "INFO" | "WARN" | "ERROR";
}).default("INFO"),
} as const;
const config = load(schema);
type Config = InferConfig<typeof schema>;Creates a string field.
const schema = {
NAME: string(),
TITLE: string().default("Untitled"),
DESCRIPTION: string().optional(),
};Creates a number field. Throws if value cannot be parsed as a number.
const schema = {
PORT: number(),
TIMEOUT: number().default(5000),
};Creates a boolean field. Accepts "true", "1" (true) and "false", "0" (false), case-insensitive.
const schema = {
DEBUG: boolean().default(false),
VERBOSE: boolean(),
};Creates an array field. Splits string by separator (default: ",").
const schema = {
ALLOWED_HOSTS: array(string()),
PORTS: array(number(), { separator: ";" }),
TAGS: array(string()).default([]),
};Creates a JSON field. Parses value with JSON.parse().
const schema = {
SETTINGS: json<{ theme: string; locale: string }>(),
METADATA: json().optional(),
};Creates a Secret field. The value is hidden when printed to the console, serialized to JSON, or inspected.
import { secret } from "configlette";
const schema = {
API_KEY: secret(),
};
const config = load(schema);
console.log(config.API_KEY); // Secret(**********)
console.log(String(config.API_KEY)); // **********
console.log(JSON.stringify(config)); // {"API_KEY":"**********"}
// Reveal the actual value
console.log(config.API_KEY.reveal()); // "actual-secret-value"Creates an enum/choice field. Validates that the value is one of the allowed choices.
const schema = {
LOG_LEVEL: oneOf(["debug", "info", "warn", "error"] as const),
ENV: oneOf(["development", "staging", "production"] as const).default(
"development",
),
};
const config = load(schema, { env: { LOG_LEVEL: "info" } });
// config.LOG_LEVEL is typed as "debug" | "info" | "warn" | "error"Creates a custom field with your own coercion function.
const schema = {
LEVEL: custom((s) => s.toUpperCase() as "DEBUG" | "INFO" | "ERROR"),
URL: custom((s) => new URL(s)),
};Sets a default value for the field. Field becomes optional in env/file.
PORT: number().default(3000);Marks field as optional. Returns undefined if not present.
API_KEY: string().optional();Uses a different environment variable name than the schema key.
const schema = {
apiKey: string().fromEnv("SERVICE_API_KEY"),
};
// Reads from SERVICE_API_KEY instead of apiKeyLoads configuration from environment variables and/or .env file.
const config = load(schema, {
envFile: ".env", // Path to .env file (optional)
envPrefix: "APP_", // Prefix for env vars (optional)
encoding: "utf8", // File encoding (default: "utf8")
env: process.env, // Custom env object (default: process.env)
environment: customEnvironment, // Custom Environment instance (optional)
interpolate: true, // Enable variable interpolation (optional)
// or with options:
interpolate: {
missing: "error", // How to handle missing refs: "error" | "leave" | "empty"
lookup: "env-first", // Where to look: "env-first" | "file-first" | "file-only" | "env-only"
},
skipMissing: false, // Skip missing required variables (optional)
});Precedence: Environment variables > .env file > defaults
Type helper to extract the TypeScript type from your schema.
const schema = {
PORT: number().default(3000),
API_KEY: string().optional(),
};
type Config = InferConfig<typeof schema>;
// { PORT: number; API_KEY: string | undefined }Wrapper around environment variables that tracks reads and prevents modification after reading.
import { environment, Environment } from "configlette";
// Global instance
environment.get("PORT");
environment.set("PORT", "3000"); // throws EnvironmentError - already read!
// Custom instance
const env = new Environment({ PORT: "3000" });Thrown when:
- A required config value is missing
- Type coercion fails
- A circular reference is detected in variable interpolation
- A referenced variable doesn't exist (with
missing: "error"policy)
try {
const config = load(schema);
} catch (error) {
if (error instanceof ConfigError) {
// Handle config error
}
}Thrown when attempting to modify an environment variable that has already been read.
environment.get("PORT");
environment.set("PORT", "8080"); // throws EnvironmentErrorEphemeral fields are read from environment variables and validated, but not included in the final config object. They're useful for intermediate values that you need during config construction but don't want in your final config.
import { ephemeral, derived, load, string, number } from "configlette";
const schema = {
// Ephemeral - read and validated but not in final config
pgHost: ephemeral(string()),
pgPort: ephemeral(number()),
pgUser: ephemeral(string()),
pgPassword: ephemeral(string()),
// Regular field - included in final config
appName: string(),
// Derived - computed from other fields
databaseUrl: derived(
(cfg) =>
`postgresql://${cfg.pgUser}:${cfg.pgPassword}@${cfg.pgHost}:${cfg.pgPort}/mydb`,
),
};
const config = load(schema, {
env: {
PG_HOST: "localhost",
PG_PORT: "5432",
PG_USER: "admin",
PG_PASSWORD: "secret",
APP_NAME: "MyApp",
},
});
console.log(config);
// {
// appName: "MyApp",
// databaseUrl: "postgresql://admin:secret@localhost:5432/mydb"
// }
// Note: pgHost, pgPort, pgUser, pgPassword are NOT in the configEphemeral fields support all field modifiers:
ephemeral(string().default("localhost"));
ephemeral(number().optional());
ephemeral(string().fromEnv("CUSTOM_VAR"));Derived fields don't read from environment variables. Instead, they're computed from other config values (both regular and ephemeral).
const schema = {
protocol: string().default("https"),
host: string(),
port: number(),
// Computed from other fields
baseUrl: derived((cfg) => `${cfg.protocol}://${cfg.host}:${cfg.port}`),
// Can return any type
isSecure: derived((cfg) => cfg.protocol === "https"),
// Can perform transformations
uppercaseHost: derived((cfg) => (cfg.host as string).toUpperCase()),
};Type Safety: The final config type automatically excludes ephemeral fields and includes derived fields with their return types:
const schema = {
temp: ephemeral(string()),
value: number(),
computed: derived((cfg) => cfg.value * 2),
};
type Config = InferConfig<typeof schema>;
// { value: number; computed: number }
// Note: 'temp' is excludedUse Cases:
- Building complex connection strings from parts
- Computing derived values without storing components
- Transforming config values
- Keeping secrets out of the final config object
Configlette automatically converts your camelCase schema keys to SCREAMING_SNAKE_CASE when looking up environment variables:
const schema = {
databaseUrl: string(), // Looks for DATABASE_URL
apiKey: string(), // Looks for API_KEY
maxRetryCount: number(), // Looks for MAX_RETRY_COUNT
};
const config = load(schema, {
env: {
DATABASE_URL: "postgres://localhost",
API_KEY: "secret",
MAX_RETRY_COUNT: "5",
},
});
console.log(config.databaseUrl); // "postgres://localhost"
console.log(config.apiKey); // "secret"
console.log(config.maxRetryCount); // 5How it works:
camelCase→CAMEL_CASEPascalCase→PASCAL_CASElowercase→LOWERCASEALREADY_SCREAMING→ALREADY_SCREAMING(unchanged)
Override with .fromEnv():
const schema = {
// Use a custom env var name instead of automatic conversion
databaseUrl: string().fromEnv("MY_CUSTOM_DB_VAR"),
};You can reference other variables in your .env file using $VAR or ${VAR} syntax:
# .env file
PGHOST=localhost
PGPORT=5432
PGUSER=admin
DATABASE_URL=postgresql://$PGUSER@$PGHOST:$PGPORT/mydbconst config = load(schema, {
envFile: ".env",
interpolate: true, // Enable interpolation
});
// DATABASE_URL will be: postgresql://admin@localhost:5432/mydbinterpolate: {
// How to handle missing variable references
missing: "error" | "leave" | "empty", // default: "error"
// Where to look for variable values
lookup: "env-first" | "file-first" | "file-only" | "env-only" // default: "env-first"
}Missing policies:
"error"- Throw an error if a referenced variable doesn't exist (default)"leave"- Leave the reference as-is (e.g.,$MISSINGstays$MISSING)"empty"- Replace with empty string
Lookup policies:
"env-first"- Check environment variables first, then .env file (default)"file-first"- Check .env file first, then environment variables"file-only"- Only look in .env file"env-only"- Only look in environment variables
// Basic interpolation
load(schema, { envFile: ".env", interpolate: true });
// Custom missing policy
load(schema, {
envFile: ".env",
interpolate: { missing: "empty" },
});
// File-only lookup (ignore environment)
load(schema, {
envFile: ".env",
interpolate: { lookup: "file-only" },
});Use \$ to include a literal dollar sign:
PRICE=\$100- Circular references are detected - An error is thrown if variables reference each other in a cycle
- Environment variables are NOT interpolated - Only .env file values are expanded for security
- Predictable precedence - Environment variables always take precedence over .env file in the final config
# Comments are ignored
DATABASE_URL=postgres://localhost/mydb
PORT=3000
DEBUG=true
# Quotes are stripped
NAME="My App"
MESSAGE='Hello World'
# Arrays use comma separator by default
ALLOWED_ORIGINS=https://example.com,https://app.example.com
# JSON values
SETTINGS={"theme":"dark","locale":"en"}
# Variable references (with interpolate: true)
PGHOST=localhost
PGPORT=5432
DATABASE_URL=postgresql://$PGHOST:$PGPORT/mydbimport { load, Environment } from "configlette";
// Create an isolated environment for testing
const testEnv = new Environment({
DATABASE_URL: "postgres://test",
PORT: "3001",
});
const config = load(schema, { environment: testEnv });In test environments (like CI), you might not have all environment variables set. You can use skipMissing to suppress errors for missing required variables:
const config = load(schema, {
// In CI, we might skip required vars that aren't needed for unit tests
skipMissing: process.env.NODE_ENV === 'test',
});
// Missing required fields will be undefined instead of throwingMIT