Skip to content

Runtime Validation

The schema.ts file contains the runtime validation logic. While Varlock generates type definitions from the .env.schema file, this file validates the loaded values at runtime.

File: apps/web/src/lib/secrets/schema.ts

Varlock type generation provides compile time safety, but data coming from Infisical or .env files is not type-safe — anything can be in them.

AspectVarlock (.env.schema)schema.ts
When it runsBuild time / at startupRuntime (after Infisical load)
What it doesTypeScript type generationValue validation
Where it’s usedenv.d.ts generationvarlock.tsloadSecretsWithFallback
FormatVarlock annotationsTypeScript code

Validates the env object against the rules in .env.schema:

export function validateSchema(env: Record<string, unknown>): Record<string, unknown> {
const errors: string[] = [];
// Check required fields
for (const key of REQUIRED_KEYS) {
if (!env[key]) {
errors.push(`Missing required variable: ${key}`);
}
}
// Type validations
if (!['development', 'production', 'test'].includes(String(env.NODE_ENV))) {
errors.push(`Type validation failed: NODE_ENV...`);
}
if (errors.length > 0) {
throw new Error(`[Varlock] Schema validation failed:\n${errors.join('\n')}`);
}
return env;
}

Validations:

  • Presence of required fields
  • Types (URL, port, boolean, number, enum)
  • Ranges (e.g. DEMO_RESET_HOUR: 0-23)
  • Special formats (e.g. DATABASE_URL must start with postgresql://)
export const REQUIRED_KEYS = [
'INFISICAL_CLIENT_ID',
'INFISICAL_CLIENT_SECRET',
'NODE_ENV',
'DATABASE_URL',
'BETTER_AUTH_SECRET',
'BETTER_AUTH_URL'
] as const;
export const EXPECTED_ENV_KEYS = [
// All variables, required + optional
'INFISICAL_CLIENT_ID',
'INFISICAL_CLIENT_SECRET',
'NODE_ENV',
// ... all others
] as const;
export function validEnvArbitrary(): fc.Arbitrary<Record<string, unknown>>

A fast-check arbitrary generator that creates random but valid env objects for tests:

// In tests:
fc.assert(
fc.property(validEnvArbitrary(), (env) => {
expect(() => validateSchema(env)).not.toThrow();
})
);
// In varlock.ts:
import { validateSchema } from './schema.js';
async function loadSecretsWithFallback(options) {
const secrets = await fetchWithRetry(infisical);
const validated = validateSchema(secrets); // ← Runs here
return { secrets: validated, source: 'infisical' };
}
const urlFields = ['APP_URL', 'ORIGIN', 'BETTER_AUTH_URL'] as const;
for (const key of urlFields) {
const value = env[key];
if (value && !isValidUrl(value)) {
errors.push(`Type validation failed: ${key} — expected: url, got: "${value}"`);
}
}
const portFields = ['ELYOS_PORT', 'SMTP_PORT', 'POSTGRES_PORT'] as const;
for (const key of portFields) {
const value = env[key];
if (value && !isValidPort(value)) {
errors.push(`Type validation failed: ${key} — expected: port (1–65535), got: "${value}"`);
}
}
const nodeEnv = env['NODE_ENV'];
if (!['development', 'production', 'test'].includes(String(nodeEnv))) {
errors.push(`Type validation failed: NODE_ENV — expected: enum(development, production, test), got: "${nodeEnv}"`);
}
const numericRangeFields = [
{ key: 'EMAIL_OTP_EXPIRES_IN', min: 1, max: 20 },
{ key: 'DEMO_RESET_HOUR', min: 0, max: 23 },
{ key: 'VERIFICATION_ROLLOUT_PERCENTAGE', min: 0, max: 100 }
];
for (const { key, min, max } of numericRangeFields) {
const value = env[key];
if (value && !isValidNumber(value, min, max)) {
errors.push(`Type validation failed: ${key} — expected: number(min=${min}, max=${max}), got: "${value}"`);
}
}
[Varlock] Schema validation failed:
Missing required variable: DATABASE_URL
Type validation failed: SMTP_PORT — expected: port (1–65535), got: "invalid"
Type validation failed: NODE_ENV — expected: enum(development, production, test), got: "staging"