Skip to content

A simple configuration for Node-Apps

Published: at 11:25 AM

I had seen and done mainly PHP and NodeJS when I started out at my first ever employment as a software developer in Berlin at the beginning of the 2010s, where I was to learn the magical world of Ruby on Rails. Such an opinionated framework was an eye-opener for me, and I since then have a little sweet spot for this type of web framework. It has done a great deal to me making a good architecture understandable in a powerfull tool when I was just starting out having had mainly worked on source code alone.

As much as I like the sheer chaos the the JavaScript community is made of, I cannot help but find a good deal of comfort in knowing there are some things sorted out by mutual approval.

As the formatter should probably be part of the language (gofmt has been such an eye-opener in this regard), I think basic application configuration should be at lease basically covered. It’s an essential part of every proram.

Some time ago I stumbled upon a blog-post making a suggestion about config file organization in elixir (which has gotten better but I find mix’ Config one of the rare weaker points of an unbelievibly fun language) and the next time I found myself setting up a new node project, defining an env.d.ts which barely brings clarity to what is really available and necessary in my environment, I had to precisly think of that elixir blog post, wondering if some typescript shenanigans could yield something which resembles this sweet pattern matching of elixir.

I came up with something I think is at lease better than just hoping env.d.ts and dotenv are matching reality:

// all possible values are defined in advance and
// can be found at one place
export const getEnv = createEnv({
  PORT: {
    fallback: "3000",
    convert: "number",
  },
  PUBLIC_URL: {
    fallback: "http://localhost:3000",
  },
  JWT_SECRET: {},
  JWT_EXPIRATION: {
    fallback: "30d",
  },
  SMTP_HOST: {},
  SMTP_PORT: {
    fallback: "587",
    convert: "number",
  },
  SMTP_USER: {},
  SMTP_PASSWORD: {},
  SMTP_SECURE: {
    fallback: "false",
    convert: "boolean",
  },
  ALLOWED_DOMAINS: {
    fallback: "rinaldoni.net,rinaldoni.xyz",
    convert: "string-array",
  },
} as const);

// this is how I imagine using a configuration should work
import { getEnv } from "config";

getEnv("JWT_SECRET"); // has a return type of string, throws when not defined in environment
getEnv("PORT"); // has a return type of number, returns 3000 when not defined in environment
getEnv("ALLOWED_DOMAINS"); // has a return type of string[], falls back to ['rinaldoni.net', 'rinaldoni.xyz']
getEnv("NOT_IMPLEMENTED"); // yields a typescript error

I have started using it on a smaller project of mine and I am very happy with it. It does seem to bring at least some clear-sightedness into the mess. It throws early, even more when you hoist the call at the beginning of your files in the module scope:

import { createServer } from "node:http";
import { getEnv } from "config";
import { createApp } from "./expressApp";

// this will probably throw on app start if it is not set
const PORT = getEnv("PORT");

export const startServer = () => {
  http.listen(createApp(), PORT);
};

It is just little more than some lines of typescript:

// define all of your environment here
const defaultEnvironment = {
    PORT: {
        fallback: '3000',
        convert: 'number'
    },
    ...
}
type EnvKey = keyof typeof defaultEnvironment;
type EnvDescriptor<T extends EnvKey> = (typeof defaultEnvironment)[T];
type EnvResult<T extends EnvKey> =
  EnvDescriptor<T> extends { convert: "number" }
    ? number
    : EnvDescriptor<T> extends { convert: "boolean" }
      ? boolean
      : EnvDescriptor<T> extends { convert: "string-array" }
        ? string[]
        : EnvDescriptor<T> extends { fallback: infer F }
          ? F
          : string;

export const getEnv = <T extends EnvKey>(key: T): EnvResult<T> => {
  const descriptor = defaultEnvironment[key];
  const value = process.env[key];
  const fallback = "fallback" in descriptor ? descriptor.fallback : undefined;

  if (value === undefined && fallback === undefined) {
    throw new Error(`Missing environment variable: ${key}`);
  }
  const result = (value !== undefined ? value : fallback) as string;

  if ("convert" in descriptor && descriptor.convert === "number") {
    return parseInt(result) as EnvResult<T>;
  }

  if ("convert" in descriptor && descriptor.convert === "boolean") {
    return ["true", "1"].includes(result.toLowerCase()) as EnvResult<T>;
  }

  if ("convert" in descriptor && descriptor.convert === "string-array") {
    return result
      .split(",")
      .map(s => s.trim())
      .filter(Boolean) as EnvResult<T>;
  }

  return result as EnvResult<T>;
};

I am quite pleased with the result and may even try to make it somewhat more generally usable.