tyfigo

Config files and layering

How tyfigo names, finds and merges base, environment and local config files.

File names

tyfigo claims files whose name ends in .config.<ext>: app.config.yaml, server.config.toml, jvm.config.properties, and the files of a .config directory. A plain data.yaml anywhere else is left alone, so it can belong to another content mapper or bundler loader.

The part before .config is the stem. Every file that shares a stem is a layer of the same config:

FileLayerRead when
app.config.yamlbasealways
app.production.config.yamlenvironmentthe environment is production
app.local.config.yamllocalalways (not in browser builds)
app.production.local.config.yamlenvironment-localthe environment is production (not in browser builds)

You always import the base file. The other layers are found from its name.

Order

Layers apply in this order, each one over the result of the ones before it:

  1. app.config.yaml
  2. app.<environment>.config.yaml
  3. app.local.config.yaml
  4. app.<environment>.local.config.yaml
  5. Environment variables, through placeholders

Environment files sit next to the base file. Local files are looked for next to the base file first, then in the project root (the nearest folder with a package.json). Keep local files out of version control:

.gitignore
*.local.config.*
.env.local

A .config directory

Instead of the .config suffix, you can keep config files in a folder named .config. A file directly inside it, with one of the format extensions (.yaml, .yml, .toml, .jsonc, .json5, .ini, .properties, .xml), is a config file. The layers follow the same naming, without the .config part:

FileLayer
.config/app.yamlbase
.config/app.production.yamlenvironment
.config/app.local.yamllocal
.config/app.production.local.yamlenvironment-local
src/main.ts
import config from '../.config/app.yaml';
  • Environment files sit next to the base file, in the same .config folder.
  • Local files are looked for next to the base file first, then in the project root's .config folder.
  • .env files are looked for in the folder that contains .config first, then in the project root, so .env sits where it usually does, not inside .config.
  • Files in folders below .config (.config/db/app.yaml) are not config files.

Running it

Nothing to set up. tyfigo/register, tyfigo/bun, tyfigo/jest (with the pattern on Jest) and every bundler plugin load .config/*.yaml and the other extensions by their path. A .yaml file anywhere else is still left alone: Bun loads it with its own loader, Node.js still refuses it, and bundlers handle it as they did before.

Type checking is opt-in

TypeScript picks a content mapper by extension, and .config/app.yaml ends in .yaml, not .config.yaml. To type it, give tyfigo the plain extensions you use in .config as well, and include the folder as .config/*:

tsconfig.json
{
	"contentMappers": [
		{
			"package": "tyfigo",
			"extensions": [
				".config.yaml",
				".config.yml",
				".config.toml",
				".config.jsonc",
				".config.json5",
				".config.ini",
				".config.properties",
				".config.xml",
				".yaml",
			],
		},
	],
	"include": ["src", ".config/*"],
}

TypeScript skips folders whose name starts with a dot when it expands a folder in include, so ".config" alone only checks the files your code imports. ".config/*" names the files and checks the environment and local files too.

tyfigo then owns every .yaml file

Each extension has one content mapper, so you can't also give .yaml to another mapper, such as Motherload. Every other .yaml file your code imports, such as fixtures.yaml, becomes plain data: typed widened and readonly like a base file, with parse errors reported, but no layers, no placeholders (${PORT:-3000} is just a string) and no .env. tyfigo types those files but doesn't load them at run time; Bun imports them natively, and elsewhere they need their own loader.

fixtures.yaml
users:
  - name: Ada
    admin: true
import type fixtures from '../fixtures.yaml';
// const fixtures: { readonly users: readonly { readonly name: string; readonly admin: boolean }[] }

The config-dir example runs this setup on Node.js.

The environment name

The environment is NODE_ENV by default. For browser builds and the Vite dev server, bundler plugins use the bundler's mode instead where there is one (Vite, webpack, Rspack, Rsbuild). A server bundle reads NODE_ENV when it starts (Server bundles). Every plugin and loader takes an environment option, which fixes it:

tyfigo({ environment: 'staging' });

With no environment, only the base and app.local.config.yaml are read.

Merging

  • Objects merge key by key.
  • Arrays and scalar values replace what was there.
  • An override may only use keys the base file has, with a value of the same kind (string, number, boolean, null, array or object).
config/app.config.yaml
server:
  host: localhost
  port: ${PORT:-3000}
features: [search]
config/app.production.config.yaml
server:
  host: 0.0.0.0
features: [search, billing]

With NODE_ENV=production, config.server is { host: '0.0.0.0', port: 8080 } (with PORT=8080) and config.features is ['search', 'billing'].

A key the base file doesn't have, or a value of another kind, is a type error in tsc (see Type checking) and a problem at run time:

/srv/app/config/app.production.config.yaml:3:3 - 'server.hostname' is not in app.config.yaml; environment and local files can only override existing keys (unknown-key)

The imported value

  • The default export is the merged config, deep-frozen with Object.freeze.
  • Its type is readonly all the way down, so assigning to it is a type error too.
  • Each import resolves once, when the module is first evaluated.

Several config files can live side by side. The Vite and Rspack examples use app.config.* for browser code and server.config.* for server code, so server-only values never reach a client bundle (see Browser builds).

On this page