Enhanced Configuration
ExpressoTS v4 introduces an enhanced configuration system that provides type-safe configuration management with helpful validation, automatic environment switching, and secure secret handling.
Overview
The enhanced configuration system provides:
- Type-Safe Config: Full TypeScript inference on configuration values
- Helpful Validation: Detailed error messages with examples and hints
- Environment Switching: Automatic config switching based on environment
- Secret Management:
SecretValuewrapper for secure handling - Environment Variables: Type-safe access to environment variables
Defining Configuration
Use defineConfig to create a type-safe configuration:
// config/app.config.ts
import { defineConfig, Env } from "@expressots/core";
export default defineConfig({
app: {
name: Env.string("APP_NAME", { default: "My API" }),
port: Env.port("PORT", { default: 3000 }),
environment: Env.string("NODE_ENV", { default: "development" }),
},
database: {
url: Env.string("DATABASE_URL", { required: true }),
pool: Env.number("DB_POOL_SIZE", { default: 10 }),
ssl: Env.boolean("DB_SSL", { default: false }),
},
features: {
caching: Env.boolean("ENABLE_CACHE", { default: true }),
rateLimit: Env.number("RATE_LIMIT", { default: 100 }),
},
api: {
version: Env.string("API_VERSION", { default: "v1" }),
prefix: Env.string("API_PREFIX", { default: "/api" }),
},
});
Environment Field Builders
The Env object provides type-safe builders for environment variables. Every builder takes the environment variable name plus a single options object:
String Values
Env.string("VAR_NAME", {
required: true, // Make required
default: "fallback", // Set default value
minLength: 1, // Minimum string length
maxLength: 100, // Maximum string length
pattern: /^[a-z]+$/, // Validate with regex
format: "email", // Or a predefined format: email, url, uuid,
// alphanumeric, hostname, ip, ipv4, ipv6
})
// Whitespace is trimmed by default (disable with trim: false).
Number Values
Env.number("VAR_NAME", {
required: true,
default: 100,
min: 1, // Minimum value
max: 1000, // Maximum value
integer: true, // Must be integer
positive: true, // Must be positive
})
Boolean Values
Env.boolean("VAR_NAME", {
required: true,
default: false,
})
// Accepts as true: "true", "1", "yes", "on"
// Accepts as false: "false", "0", "no", "off"
// Extend with trueValues / falseValues options.
Array Values
Env.array("VAR_NAME", {
required: true,
default: ["item1", "item2"],
delimiter: ",", // Custom delimiter (default: ",")
itemType: "string", // "string" (default) or "number"
unique: true, // Remove duplicates
})
// VAR_NAME=item1,item2,item3 → ["item1", "item2", "item3"]
Object Values
Env.json<{ key: string }>("VAR_NAME", {
required: true,
default: { key: "value" },
validate: (v) => typeof v.key === "string" || "key must be a string",
})
// VAR_NAME='{"key":"value"}' → { key: "value" }
URL Values
Env.url("VAR_NAME", {
protocols: ["http", "https"],
noTrailingSlash: true,
development: "http://localhost:4000",
production: "https://api.example.com",
description: "External service base URL",
})
Port Values
Env.port("PORT", {
default: 3000,
description: "HTTP server port",
})
// Range-validated (1-65535) and type-cast to number.
Enum Values
Env.enum("LOG_LEVEL", ["debug", "info", "warn", "error"], {
development: "debug",
production: "warn",
})
// Inferred as "debug" | "info" | "warn" | "error".
Secret Values
Env.secret("JWT_SECRET", {
minLength: 32,
description: "Secret key for signing JWTs",
hint: "Generate with: openssl rand -base64 32",
})
// Returns a SecretValue, automatically redacted in logs.
See SecretValue Features for the full API surface.
Using Configuration
In Services
Import the config instance exported from your config module and read config.values (fully typed):
import { provide } from "@expressots/core";
import config from "../config/app.config";
@provide(DatabaseService)
export class DatabaseService {
connect() {
const dbUrl = config.values.database.url; // string
const poolSize = config.values.database.pool; // number
// TypeScript knows the types!
return this.createPool(dbUrl, poolSize);
}
}
For dynamic access, config.get() accepts a dot-notation path (the return type defaults to unknown, so pass a type parameter):
const dbUrl = config.get<string>("database.url");
const poolSize = config.get<number>("database.pool");
// Check whether a value is set
if (config.has("features.caching")) {
// ...
}
In Application Class
Access configuration the same way inside your application class:
import { AppExpress } from "@expressots/adapter-express";
import config from "./config/app.config";
export class App extends AppExpress {
async globalConfiguration(): Promise<void> {
this.setGlobalRoutePrefix(config.values.api.prefix);
}
async configureServices(): Promise<void> {
if (config.values.features.caching) {
this.Middleware.addCaching();
}
if (config.values.features.rateLimit > 0) {
this.Middleware.addRateLimiter({
max: config.values.features.rateLimit,
});
}
}
}
Helpful Validation
When configuration is invalid, ExpressoTS provides detailed error messages:
❌ Configuration Validation Failed
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. DATABASE_URL (missing)
└─ Required environment variable DATABASE_URL is not set
├─ Expected: Non-empty string
├─ Example: postgresql://user:password@localhost:5432/mydb
└─ 💡 Hint: Add DATABASE_URL to your .env file
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ Server will not start until configuration is valid.
By default validation runs on first access to config.values. Errors are logged in every environment; in production (throwOnError defaults to true when NODE_ENV=production) they also throw and abort startup.
Validation Examples
// Required field missing
Env.string("API_KEY", { required: true })
// ❌ Required environment variable API_KEY is not set
// Out of range number
Env.number("PORT", { min: 1, max: 65535 })
// PORT=99999
// ❌ PORT must be at most 65535
// Invalid enum
Env.enum("LOG_LEVEL", ["debug", "info", "warn", "error"])
// LOG_LEVEL=verbose
// ❌ LOG_LEVEL must be one of the allowed values
// Expected: One of: debug, info, warn, error
// Pattern mismatch
Env.string("SLUG", { pattern: /^[a-z0-9-]+$/ })
// SLUG=My Slug
// ❌ SLUG does not match the required pattern
Environment-Specific Configuration
Use the conditional helpers Env.is() and Env.when() to pick values per environment:
Env.is(name)returnstruewhenNODE_ENVmatchesname(case-insensitive, defaults to"development"when unset). Pass an array to match any of several names.Env.when(condition, value, fallback)returnsvaluewhen the condition is truthy, otherwisefallback. The condition can be a boolean (typicallyEnv.is(...)) or a function evaluated lazily.
import { defineConfig, Env } from "@expressots/core";
export default defineConfig({
database: {
url: Env.string("DATABASE_URL", {
development: "postgresql://localhost:5432/dev",
test: "postgresql://localhost:5432/test",
required: true,
}),
pool: Env.when(Env.is("production"), 20, 5),
},
logging: {
level: Env.when(Env.is(["production", "staging"]), "error", "debug"),
pretty: Env.when(() => process.env.NO_COLOR !== "1", true, false),
},
});
Static values returned by Env.when() are preserved as-is in the resolved config. The same helpers are also exported as the free functions envIs and envWhen.
Inline multi-environment defaults
Every Env.* builder also accepts per-environment defaults directly in its options object. This is the most concise way to express "use one default in dev, another in production":
import { defineConfig, Env } from "@expressots/core";
export const config = defineConfig({
server: {
port: Env.port("PORT", { default: 3000 }),
host: Env.string("HOST", {
development: "localhost",
staging: "0.0.0.0",
production: "0.0.0.0",
description: "Server bind address",
}),
},
database: {
url: Env.url("DATABASE_URL", {
development: "postgresql://localhost:5432/dev_db",
staging: undefined, // Must be set explicitly
production: undefined, // Must be set explicitly
description: "PostgreSQL connection URL",
hint: "Format: postgresql://user:password@host:port/database",
}),
poolSize: Env.number("DB_POOL_SIZE", {
development: 5,
staging: 20,
production: 50,
min: 1,
max: 100,
}),
},
auth: {
jwtSecret: Env.secret("JWT_SECRET", {
minLength: 32,
description: "Secret key for signing JWTs",
hint: "Generate with: openssl rand -base64 32",
}),
},
features: {
allowedOrigins: Env.array("ALLOWED_ORIGINS", {
default: ["http://localhost:3000"],
delimiter: ",",
description: "Allowed CORS origins (comma-separated)",
}),
enableRateLimit: Env.boolean("ENABLE_RATE_LIMIT", {
development: false,
staging: true,
production: true,
}),
},
});
export type AppConfig = typeof config.values;
The resolved environment is picked from NODE_ENV. Passing undefined for a given environment means "no default for that environment": the value must come from the environment variable itself. To make startup fail when a variable is missing, declare the field with { required: true }.
Alternative: Multiple Config Files
// config/development.ts
import { defineConfig } from "@expressots/core";
export default defineConfig({
database: { url: "postgresql://localhost:5432/dev" },
logging: { level: "debug" },
});
// config/production.ts
import { defineConfig, Env } from "@expressots/core";
export default defineConfig({
database: { url: Env.string("DATABASE_URL", { required: true }) },
logging: { level: "error" },
});
// config/index.ts
import { Env } from "@expressots/core";
import development from "./development";
import production from "./production";
export default Env.is("production") ? production : development;
Secret Management
Use Env.secret() for sensitive data. The resolved value is a SecretValue wrapper instead of a plain string:
import { defineConfig, Env } from "@expressots/core";
const config = defineConfig({
auth: {
jwtSecret: Env.secret("JWT_SECRET", { required: true, minLength: 32 }),
apiKey: Env.secret("API_KEY", { required: true }),
},
});
SecretValue Features
import type { SecretValue } from "@expressots/core";
const secret: SecretValue = config.values.auth.jwtSecret;
// Access the actual value
const value = secret.value; // Returns the full secret string
// Safe logging (won't expose the secret)
console.log(secret.toString()); // "[REDACTED]" (partial reveal in development)
console.log(secret.toJSON()); // "[REDACTED]" (always, in every environment)
console.log(config.toObject()); // Secrets replaced with "[REDACTED]"
// Partial reveal for debugging: shows the last few characters,
// e.g. "...z789". Only works when NODE_ENV=development and
// allowPartialReveal is enabled (the default); otherwise it
// returns "[REDACTED]". It never returns the full secret.
console.log(secret.reveal());
// Timing-safe comparison without exposing the value
secret.equals("expected-value"); // Returns true/false
// Introspection without exposing the value
secret.isSet; // true if non-empty
secret.length; // number of characters
How many characters reveal() and the development toString() show is controlled by the revealStart (default 0) and revealEnd (default 4) options on Env.secret(). Set allowPartialReveal: false to always get "[REDACTED]".
Configuration in expressots.config.ts
You can also use configuration in expressots.config.ts:
// expressots.config.ts
import { ExpressoTSConfig } from "@expressots/shared";
const config: ExpressoTSConfig = {
sourceRoot: "src",
entryPoint: {
opinionated: "main.ts",
nonOpinionated: "main.ts",
},
env: {
development: ".env.development",
production: ".env.production",
test: ".env.test",
},
};
export default config;
Configuration Schema
The schema passed to defineConfig doubles as documentation. Use the description and example options to enrich validation errors and generated docs:
import { defineConfig, Env } from "@expressots/core";
const config = defineConfig({
database: {
url: Env.string("DATABASE_URL", {
required: true,
description: "Database connection URL",
example: "postgresql://user:pass@localhost:5432/db",
}),
pool: Env.number("DB_POOL_SIZE", {
default: 10,
min: 1,
max: 100,
description: "Connection pool size",
}),
},
});
export default config;
// Validate explicitly and inspect the result
const result = config.validate();
if (!result.valid) {
console.error(config.getErrors());
}
// Generate a configuration reference from the schema
const markdown = config.generateDocs("markdown"); // default format
const json = config.generateDocs("json");
// List every environment variable the schema reads
config.getEnvVars(); // ["DATABASE_URL", "DB_POOL_SIZE"]
Best Practices
- Use Type-Safe Builders: Always use
Env.string(),Env.number(), etc. - Provide Defaults: Use the
defaultoption for optional configuration - Mark Required Fields: Use
{ required: true }for essential config - Use Secrets: Declare sensitive data with
Env.secret()so it resolves to aSecretValue - Validate Early: Call
config.isValid()(or accessconfig.values) at startup - Document Config: Use the
descriptionandexampleoptions for documentation
Migration from v3
The v3 Env provider (with checkFile() / checkAll()) has been replaced in v4 by defineConfig and the Env.* builders:
// v3 style (removed in v4)
// const port = this.Provider.get(Env).get("PORT");
// v4 style
import { defineConfig, Env } from "@expressots/core";
export const config = defineConfig({
server: {
port: Env.port("PORT", { default: 3000 }),
},
});
const port = config.values.server.port; // number
See the Env validator page for a full mapping from the legacy provider to the v4 equivalents.
Comparison with Other Frameworks
| Feature | ExpressoTS | NestJS | Spring Boot |
|---|---|---|---|
| Type-Safe Config | ✅ Full inference | ⚠️ Partial | ⚠️ Via annotations |
| Helpful Validation | ✅ Examples & hints | ❌ Basic errors | ⚠️ Basic errors |
| Environment Switching | ✅ Automatic | ⚠️ Manual | ✅ Profiles |
| Secret Management | ✅ SecretValue | ❌ Manual | ⚠️ Via Vault |
| Schema Definition | ✅ Built-in | ❌ Via class-validator | ✅ Via annotations |
Support the Project
ExpressoTS is MIT-licensed open source. See the support guide to contribute.