The simple solution for guaranteeing valid and type-safe application configuration
Dec 11, 2025
This will be a brief post, because, fortunately, there’s not a whole lot to say. Zod and Nest’s ConfigService do much of the heavy lifting. We just need a little bit of glue to make it all stick together nicely!
I also want to acknowledge that there are many ways to inject configuration values into an application, but I’ve found environment variables to be the most widely supported and simple to adopt. They have their shortcomings, but, as you’ll see here, we’re able to overcome most of them pretty well.
Here are the basic steps we’ll follow:
config.ts file in project root to hold our Zod schema.ConfigModule in app.module.ts to use our recently created config schema.ApiConfigService that lightly wraps Nest’s ConfigService to add type safety by default.Okay, let’s dive right in!
Earlier I referred to drawbacks of using environment variables. The main two that I encounter are their inherent “flat” nature and their lack of types. For example, on a previous team we would use deeply nested YAML config files, which required some process for “de-flattening” the environment variables and converting their string values as needed to numbers, arrays, or even JSON objects. This process was abstracted away and often difficult to understand, which would sometimes lead to obscure and hard to debug errors.
To address the “flat” issue, I’ve recently found I prefer simply embracing flat config. You may say, “Oh, your project isn’t that complex”, and you may be right 😉. However, even in the case of 50, 100, or more configuration values, I think there is something gained from the simplicity of mapping environment variables directly to the project’s config class or object. It reduces ambiguity and the cognitive overhead of setting up the environment variables.
And regarding the typing issue, my answer to that is Zod. Let’s take a look:
// src/config.ts
import { z } from "zod";
export const configSchema = z.object({
// --> While the values are flat, it's still nice to keep similar configs grouped together.
// GENERAL
PORT: z.coerce.number().optional().default(3000), // --> Use Zod's coercion to convert types.
FRONTEND_URL: z.string(),
// DATABASE
DATABASE_URL: z.string(),
// AUTH
AUTH_JWKS_URI: z.string(),
AUTH_ISSUER: z.string(),
AUTH_AUDIENCE: z.string(),
AUTH_CLIENT_ID: z.string(),
AUTH_CLIENT_SECRET: z.string(),
AUTH_REFRESH_INTERVAL_SECONDS: z.coerce.number().default(58),
// CORS
CORS_ALLOWED_ORIGINS: z
.string()
.default("")
.transform((val) => val.split(",")), // --> An example of Zod's powerful transform method
// converting this comma-separated value into an array.
// Key Value Store
KV_STORE_HOST: z.string().default("localhost"), // --> Set defaults!
KV_STORE_PORT: z.coerce.number().default(6379),
KV_STORE_CONNECT_TIMEOUT: z.coerce.number().default(5000),
// ...other config values as needed.
});
// --> Let Zod infer the type of the schema so we can have type safety!
export type Config = z.infer<typeof configSchema>;
I want to re-emphasize the power of Zod’s transforms. You can convert your environment variable values into whatever you need!
Now we need to go to our app.module.ts to plug our new config schema into the ConfigModule:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { configSchema } from './config';
@Module({
imports: [
ConfigModule.forRoot({
cache: true, // --> (Optional) Cache to avoid slowdowns from accessing process.env
validate: (env) => configSchema.parse(env), // --> Simply use Zod's parse method to build
// the config object from our schema. Zod will throw an error on app startup if environment
// variables are insufficient or invalid.
}),
// ...other module imports.
],
// ...providers, controllers, etc.
});
export class AppModule {}
We now have our config service in place, which we could begin to use as is. The issue is that there’s some overhead to getting type safety. For example, if you just import and use the default config service, you won’t get type hints or typed return values:
@Injectable()
export class SomeService {
constructor(private readonly config: ConfigService) {
// Problems:
// 1. The `get` method accepts any string. No hints or enforcement of valid keys.
// 2. The type of `dbUrl` is `any`. Not helpful!
const dbUrl = config.get("DATABASE_URL");
}
}
Luckily, with a little extra setup, we can fix these problems. Instead of adding this extra setup to every single service that imports config, let’s just create our own config module!
// src/config/api-config.service.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Config } from "../config";
@Injectable()
export class ApiConfigService {
// Adding the `Config` type inferred from our Zod schema as a parameter, `ConfigService` will
// now be able to enforce type safety.
constructor(private readonly config: ConfigService<Config, true>) {} // --> The `true` here means
// "Yes", our config schema was validated.
// Wrap the default `get` function, setting `infer` to `true`.
get<T extends keyof Config>(key: T) {
// The `infer` option is what tells the config service we want typed return values.
return this.config.get(key, { infer: true });
}
}
// src/config/api-config.module.ts
import { Global, Module } from "@nestjs/common";
import { ApiConfigService } from "./api-config.service";
// Provide and export our new config service globally, and we're done!
@Global()
@Module({
providers: [ApiConfigService],
exports: [ApiConfigService],
})
export class ApiConfigModule {}
You can now go ahead and start using the new config service:
@Injectable()
export class SomeService {
constructor(private readonly config: ApiConfigService) {
// What's better:
// 1. The `get` method only accepts valid config keys and provides hinting.
// 2. The type of `dbUrl` is correctly typed! Hooray!
const dbUrl = config.get("DATABASE_URL");
}
}
Enjoy fewer headaches managing your configuration!
Comments? Would've done something different?
Please send me an email at [email protected].