Type checking
What tsc checks in your config files and your code, and where it reports it.
tsc --noEmit --runExternalCode runs tyfigo's content mapper for every config file in the program. The mapper only reads the
files: no code from your project runs inside tsc, and values that depend on the environment are checked when the app starts.
How a config file is typed
The base file declares the type. Values are widened, everything is readonly, and arrays become readonly T[]:
server:
host: localhost
port: ${PORT:-3000}
database:
url: ${DATABASE_URL}
pool:
max: 5
features: [search]import config from '../config/app.config.yaml';
// const config: {
// readonly server: { readonly host: string; readonly port: number };
// readonly database: { readonly url: string; readonly pool: { readonly max: number } };
// readonly features: readonly string[];
// }| Value in the file | Type |
|---|---|
localhost | string |
3000, 0.25 | number |
true | boolean |
null (YAML ~, JSON null) | null |
[a, b] | readonly string[] |
[1, a] | readonly (string | number)[] |
[] | readonly unknown[] |
${VAR:-3000} | the default's type, number |
${VAR}, ${VAR:?message} | string |
Types are widened on purpose: an environment file can change localhost to 0.0.0.0, so the type is string, not
'localhost'. Use a schema when you want enums or literal types.
Environment and local files don't add keys to the type. Importing app.production.config.yaml gives you the type of
app.config.yaml.
What tsc reports
Each of these lines is real tsc output for the base file above and this environment file:
server:
port: 8080
hostname: 0.0.0.0
database:
pool:
max: twenty
features: [search, 42]config/app.production.config.yaml(3,3): error TS2353: Object literal may only specify known properties, and 'hostname' does not exist in type '{ readonly host?: string | undefined; readonly port?: number | undefined; }'.
config/app.production.config.yaml(6,5): error TS2322: Type 'string' is not assignable to type 'number'.
config/app.production.config.yaml(7,20): error TS2322: Type 'number' is not assignable to type 'string'.An unknown key, a value of the wrong type and a wrong array item are each reported at their line and column in the YAML, and your editor underlines them there.
Your code is checked against the inferred type:
import config from '../config/app.config.yaml';
const port: string = config.server.port;
config.server.host = 'example.com';
console.log(config.cache);src/main.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'.
src/main.ts(4,15): error TS2540: Cannot assign to 'host' because it is a read-only property.
src/main.ts(5,20): error TS2339: Property 'cache' does not exist on type '{ readonly server: { readonly host: string; readonly port: number; }; readonly database: { readonly url: string; readonly pool: { readonly max: number; }; }; readonly features: readonly string[]; }'.Syntax errors, duplicate keys and malformed placeholders come from tyfigo itself, with a tyfigo code:
config/app.production.config.yaml(4,1): error tyfigo1001: Flow map in block collection must be sufficiently indented and end with a }A tyfigo error such as a syntax error stops tsc from reporting the project's other type errors until it is fixed. That's how tsc treats syntax errors in any file.
With loadConfig, a config that can't match the schema's input type is reported at the call:
import { loadConfig } from 'tyfigo';
import { z } from 'zod';
import config from '../config/app.config.yaml';
const Config = z.object({ server: z.object({ port: z.string() }) });
export const settings = loadConfig(config, Config);src/main.ts(7,36): error TS2345: Argument of type '{ readonly server: { readonly host: string; readonly port: number; }; readonly database: { readonly url: string; readonly pool: { readonly max: number; }; }; readonly features: readonly string[]; }' is not assignable to parameter of type '{ readonly server: { readonly port: string; }; }'.
The types of 'server.port' are incompatible between these types.
Type 'number' is not assignable to type 'string'.What tsc checks without a schema
A plain import is typed from the base file alone, strictly and widened. That has four limits. Each one has the same fix:
pass the import through loadConfig with a schema, and use what it returns.
Strict keys: no optional sections
An environment file can only override keys the base file has, so it can't add a section:
name: appsentry:
dsn: https://key@o1.ingest.sentry.io/1config/app.production.config.yaml(1,1): error TS2353: Object literal may only specify known properties, and 'sentry' does not exist in type '{ readonly name?: string | undefined; }'.Declare the section in the base file with its off value, and let a schema describe both shapes:
name: app
sentry:
enabled: false
dsn: ''sentry:
enabled: true
dsn: https://key@o1.ingest.sentry.io/1const Config = z.object({
sentry: z.discriminatedUnion('enabled', [
z.object({ enabled: z.literal(false) }),
z.object({ enabled: z.literal(true), dsn: z.url() }),
]),
});Widened values: no literal types
logLevel: info is string, not 'info' | 'debug', because another environment file can set any string:
const level: 'debug' | 'info' = config.logLevel;
// error TS2322: Type 'string' is not assignable to type '"debug" | "info"'.A schema narrows it. loadConfig accepts a widened string where the schema expects a literal, and checks the value when it
runs:
const Config = z.object({ logLevel: z.enum(['debug', 'info', 'warn', 'error']) });
const settings = loadConfig(config, Config);
// settings.logLevel: 'debug' | 'info' | 'warn' | 'error'A placeholder without a default is a string
${WORKERS} has nothing to take a type from, so it is string, and it stays a string when the app runs:
const workers: number = config.workers;
// error TS2322: Type 'string' is not assignable to type 'number'.Give it a default (${WORKERS:-4} is a number), or coerce it with a schema when it has to be set:
const Config = z.object({ workers: z.coerce.number().int().positive() });Schema mismatches are reported at the call
With loadConfig, a file whose type can't match the schema is a type error at the loadConfig(...) call in your code
(above), not on the YAML line. When it runs, a value the schema rejects is reported at its line in the
file that set it:
Invalid config /srv/app/config/app.config.yaml:
/srv/app/config/app.staging.config.yaml:1:11 - logLevel: Invalid option: expected one of "debug"|"info"|"warn"|"error"
/srv/app/config/app.config.yaml:3:10 - workers: Too small: expected number to be >0Keep one loadConfig call per config file next to its schema, and import the result instead of the file, so every check
happens in one place.
What tsc doesn't check
- Whether environment variables are set, and their values. Those are read when the app starts.
- Refinements such as ranges, URL formats or string patterns. Add a schema.
- Local files that are not in the program. Put your config folder in
includeso every layer is checked.
Next.js
next build runs its own type check, and there's no way to pass --runExternalCode to it, so it can't type config imports.
withTyfigo turns Next's check off (typescript.ignoreBuildErrors) and runs tsc --noEmit --runExternalCode itself after
the compile. Pass { typeCheck: false } when CI runs tsc separately. See Next.js.