Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Validating and Typing Environment Configuration at Startup

Hello! Last lesson gave your TypeScript service separate commands for developing from source, building JavaScript, and running the built application. The server still has one hard-coded runtime choice: port 3000. This lesson moves runtime settings out of the code while ensuring the application rejects missing or invalid settings before it starts listening.

You’ll add a configuration module to that same project, use its typed values in the HTTP server, and check both successful and failed startups. Allow about 40 minutes, including the short resources and implementation.


Treat the environment as input, not as trusted configuration

Node exposes environment variables through process.env. A value there is a string—or it is absent. TypeScript cannot establish from process.env.PORT that a port exists, is numeric, or falls within the range a server can use. The check must happen at runtime.

Watch Lazar Nikolov’s Validate your environment variables with Zod for the core pattern: define a schema, parse process.env, and export the parsed result. The final segment shows why giving process.env the schema’s output types is misleading: a parsed port can be a number, but its original environment value is still a string.

Validate your environment variables with Zod

Watch Lazar Nikolov’s Validate your environment variables with Zod to see runtime validation and TypeScript inference working together.

Watch the schema setup for the problem and the definition of expected variables. Continue with parsing and export, focusing on the difference between raw input and the typed result. In the typing caveat, notice why you should use the validated object rather than pretend that process.env itself contains parsed numbers.

A TypeScript declaration alone would only change what the compiler believes. Validation changes what the running program knows. It also gives the service a clear failure point: an invalid port should prevent startup, not surface later as an unexpected server error.

For the operational reason behind this pattern, read the opening of “Validating Environment Variables” and the focused sections on centralized configuration and local files in Environment Variables in Node.js.

Environment Variables in Node.js

Read the sections on startup validation and structuring configuration. They explain why validation belongs at the application boundary and how the rest of the code should obtain settings.

In “Validating Environment Variables,” read the opening rationale, beginning with “Reading environment variables is easy” and ending with the recommendation to fail at startup. In “Structuring Your Config Module,” read the config pattern, from “Regardless of which validation approach you choose” through the paragraph ending “not at runtime.” Focus on having one module validate and export configuration rather than having each consumer read process.env. Under “Security Best Practices,” find “Keep .env Files Out of Version Control.” Read the file guidance, from the instruction to add .env to .gitignore through the .env.example sample ending in NODE_ENV=development.

One distinction matters for the workflows you built previously: npm run check and npm run build do not execute your configuration module. They can check its TypeScript, but only starting the application validates the values supplied to that particular process.


Build one typed configuration module

In the existing project, install Zod as a runtime dependency:

npm install zod

Create src/config.ts. For this small server, GREETING is a required, nonempty string that will appear in its response. PORT is optional but, when supplied, must parse to an integer from 1 to 65535. NODE_ENV accepts only the three listed modes. The defaults make local startup convenient; a deployment should explicitly provide its intended settings.

// src/config.ts
import { z } from "zod";

const environmentSchema = z.object({
  GREETING: z.string().trim().min(1, "GREETING must not be empty"),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  NODE_ENV: z
    .enum(["development", "test", "production"])
    .default("development")
});

const result = environmentSchema.safeParse(process.env);

if (!result.success) {
  for (const issue of result.error.issues) {
    console.error(
      `Invalid configuration: ${issue.path.join(".")}: ${issue.message}`
    );
  }
  process.exit(1);
}

export const config = Object.freeze({
  greeting: result.data.GREETING,
  port: result.data.PORT,
  nodeEnv: result.data.NODE_ENV
});

export type Config = typeof config;

Follow the boundary in this code carefully. safeParse checks the actual values at startup. Only its successful result is used to construct config. TypeScript then infers config.port as a number, config.greeting as a string, and config.nodeEnv as a union of the permitted mode strings. Object.freeze prevents reassignment of these top-level settings; it is not a substitute for validation.

z.coerce.number() performs the string-to-number conversion. For example, an environment value of "4100" becomes the number 4100 in config.port; "banana" fails validation. A missing PORT gets the default 3000. A supplied but invalid value does not silently receive that default.

The error output identifies invalid fields without printing all of process.env. As you add credentials to a service, avoid logging their raw values in startup errors.


Make the server consume only validated values

Replace src/index.ts with the following version. It retains the local import from the previous lesson and adds the configuration import using the .js specifier required by your NodeNext setup.

// src/index.ts
import { createServer } from "node:http";
import { config } from "./config.js";
import { greeting } from "./greeting.js";

const server = createServer((request, response) => {
  if (request.url !== "/") {
    response.writeHead(404);
    response.end("Not found");
    return;
  }

  response.writeHead(200, {
    "content-type": "text/plain; charset=utf-8"
  });
  response.end(`${config.greeting}: ${greeting()}`);
});

server.listen(config.port, () => {
  console.log(`Listening on http://localhost:${config.port}`);
});

Importing config evaluates the validation module before this server begins listening. If validation fails, the process exits with a nonzero status instead of accepting requests under uncertain settings. From this point on, application code should import config rather than independently reading process.env; otherwise, it bypasses the boundary you just established.

Document the inputs in a project-root .env.example:

GREETING=Welcome
PORT=3000
NODE_ENV=development

This file is documentation, not an automatic loader. The commands below set variables directly in the terminal; merely creating .env.example or .env will not make this program read it. If you later use a local .env file, keep it out of version control and arrange for it to be loaded before configuration validation runs.


Verify startup, types, and failure behavior

First, check and build the updated project:

npm run check
npm run build

Then start the built application with a greeting. In a POSIX shell:

GREETING="Welcome" PORT=4100 npm start

In PowerShell, set the variables before running the command:

$env:GREETING = "Welcome"
$env:PORT = "4100"
npm start

Request http://localhost:4100/. The response should begin Welcome:, and the listening message should show port 4100. Notice that the shell supplied a string, while server.listen received the parsed config.port.

Stop the server, then try an invalid port such as PORT=banana while keeping GREETING set. Run npm start again. You should see a configuration error and no listening message. Removing GREETING should likewise prevent startup; in PowerShell, you can remove a variable with Remove-Item Env:GREETING, while in a POSIX shell you can use unset GREETING before starting. If you set variables for a shell session, clear or change them as needed between checks.

These checks establish two different guarantees. The TypeScript commands verify that code uses the types correctly. Starting the application verifies that its current deployment environment supplies valid values. Both are necessary.


Takeaway

Your service now validates its environment once, before it listens, and exposes a typed configuration object to the rest of the application. Raw environment strings stay at the startup boundary; consumers receive checked values such as a numeric port. Missing or malformed configuration produces an immediate, identifiable failure instead of an unpredictable problem during a request.

Next, you’ll examine how asynchronous and CPU-intensive work affects the responsiveness of this running Node.js server.

Can't find a good explanation? Sign up and we'll make it for you

Sign up