Skip to main content
Version: 4.0.0-preview

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: SecretValue wrapper 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) returns true when NODE_ENV matches name (case-insensitive, defaults to "development" when unset). Pass an array to match any of several names.
  • Env.when(condition, value, fallback) returns value when the condition is truthy, otherwise fallback. The condition can be a boolean (typically Env.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

  1. Use Type-Safe Builders: Always use Env.string(), Env.number(), etc.
  2. Provide Defaults: Use the default option for optional configuration
  3. Mark Required Fields: Use { required: true } for essential config
  4. Use Secrets: Declare sensitive data with Env.secret() so it resolves to a SecretValue
  5. Validate Early: Call config.isValid() (or access config.values) at startup
  6. Document Config: Use the description and example options 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

FeatureExpressoTSNestJSSpring 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.