DEV Community

Jonathan Fielding
Jonathan Fielding

Posted on

Typesafe application configuration in TypeScript

If you've ever had a deployment succeed but then the service acts weird, there's a decent chance config was the culprit.

Config bugs are painful because:

  • they often pass code review ("it's just config")
  • deployments can look healthy until the broken path is hit
  • secrets make debugging harder (you can't safely log everything)
  • and TypeScript can give a false sense of safety because config is often a quiet loophole where any creeps back in

In this post:

  • why typesafe config matters (and what goes wrong without it)
  • why validating config at startup is a big deal
  • how zodified-config solves both using Zod
  • copy/paste examples you can drop into your app

Why typesafe configuration matters (and what can go wrong without it)

A common pattern in Node.js apps is using a config library like node-config:

import config from "config";

const port = config.get("port");
Enter fullscreen mode Exit fullscreen mode

The issue is that config.get() is often effectively untyped inside your app. In many setups it returns any (or unknown), which means TypeScript can't catch mistakes.

For example, you expect port to be a number:

import config from "config";

const port: number = config.get("port");
Enter fullscreen mode Exit fullscreen mode

This compile but if your config value is "3000" (a string), you may run into runtime problems when you do:

app.listen(port);
Enter fullscreen mode Exit fullscreen mode

Even worse are subtle bugs:

import config from "config";

const enableMetrics = config.get("metrics.enabled");

if (enableMetrics) {
  startMetricsServer();
}
Enter fullscreen mode Exit fullscreen mode

If metrics.enabled is the string "false", it's truthy, and suddenly metrics are enabled in production when they shouldn't be.

That's how config issues turn into scattered, confusing “ghost bugs”.


Why validating configuration at startup is a big deal

Types help you use config safely. Validation helps you ensure the config is actually valid before the app starts serving traffic.

Without startup validation you often get this pattern:

  • app boots “fine”
  • first request hits a codepath that needs config
  • runtime explosion
  • sometimes the service crashes entirely

A rule I like:

If the app can't run correctly without a config setting, fail fast before you start serving traffic.

This is especially useful in Kubernetes: new pods should only become “ready” if their config is valid.

Practical wins of startup validation

  1. Prevent broken deployments

    Missing values (DB URLs, API keys, queue names) should crash immediately, not halfway through a request.

  2. Faster feedback loops

    Misconfiguration becomes obvious in startup logs (and CI/CD), rather than waiting for a user to trigger it.

  3. Clear error messages

    Schema validation can tell you exactly what's wrong:

    • which key is missing
    • which key has the wrong type
    • which nested value failed validation

Introducing zodified-config

Once you want typesafe config and startup validation, you end up wanting the same workflow everywhere:

  • define a schema (runtime contract)
  • validate config once, on boot
  • access config with types and autocomplete across your app

That's what zodified-config is for.

It lets you:

  • define your config schema using Zod
  • keep using the familiar node-config file structure (config/default.json, etc.)
  • validate at startup (fail fast)
  • get TypeScript types for config access everywhere

It's built as a wrapper around node-config, so you keep existing behaviour just with Zod-backed validation and type safety.


How to use zodified-config (step-by-step)

1) Install

If you're using CommonJS:

npm i zodified-config
Enter fullscreen mode Exit fullscreen mode

If you're using ESM:

npm i zodified-config-esm
Enter fullscreen mode Exit fullscreen mode

2) Define your schema

// src/schema.ts
import z from "zod";

export const configSchema = z.object({
  value: z.string(),
});

export type Config = z.infer<typeof configSchema>;
Enter fullscreen mode Exit fullscreen mode

This schema is the runtime contract, and z.infer gives you compile-time types from that same source.


3) Define your configuration files

Because this wraps node-config, you use the same structure:

// config/default.json
{
  "value": "hello world"
}
Enter fullscreen mode Exit fullscreen mode

4) Validate at startup (fail fast)

Declare your validated config type using TypeScript module augmentation, then validate on boot:

// src/index.ts
import config from "zodified-config";
import { configSchema } from "./schema";
import type { Config } from "./schema";

declare module "zodified-config" {
  interface ValidatedConfig extends Config {}
}

config.validate(configSchema);
Enter fullscreen mode Exit fullscreen mode

If config is invalid, validation throws at startup, exactly when you want to find out.

(You can wrap it in a try/catch if you want to customise the error output.)


5) Access values with type safety

Now anywhere in your app:

// src/other.ts
import config from "zodified-config";

const value = config.get("value");
// value is typed as string

console.log(value);
Enter fullscreen mode Exit fullscreen mode

This is where it starts to feel really good:

  • autocomplete for config keys
  • compile-time safety for config usage
  • fewer “stringly-typed” surprises

A more realistic example

Here's what this looks like for a typical service:

// src/schema.ts
import z from "zod";

export const configSchema = z.object({
  env: z.enum(["development", "test", "production"]),
  port: z.number(),
  database: z.object({
    url: z.string().url(),
    poolSize: z.number().int().positive(),
  }),
  features: z.object({
    metrics: z.boolean(),
  }),
});

export type Config = z.infer<typeof configSchema>;
Enter fullscreen mode Exit fullscreen mode

After validating at startup, access becomes boring (in the best way):

import config from "zodified-config";

const port = config.get("port"); // number
const dbUrl = config.get("database.url"); // string
const metricsEnabled = config.get("features.metrics"); // boolean
Enter fullscreen mode Exit fullscreen mode

If someone breaks config in an environment:

  • the app fails immediately
  • you get a clear validation error
  • you don't ship a “looks healthy but is doomed” deployment

Conclusion

TypeScript gives you a lot of safety but configuration is often the escape hatch where untyped values creep back in and quietly undermine it.

Defining a schema for config:

  • makes config access typesafe
  • improves editor autocomplete and catches key mistakes earlier

Validating config at startup:

  • prevents broken deployments
  • makes issues obvious in logs and CI/CD
  • reduces “works on my machine” surprises
  • gives you confidence changing config over time

Tools like zodified-config keep your code and config in sync and it's one of those small changes that makes a big difference to reliability.

If you try it, I'd love to hear how it goes.

Top comments (0)