# Type checking (/docs/type-checking)

`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 [#how-a-config-file-is-typed]

The base file declares the type. Values are widened, everything is `readonly`, and arrays become `readonly T[]`:

```yaml title="config/app.config.yaml"
server:
  host: localhost
  port: ${PORT:-3000}
database:
  url: ${DATABASE_URL}
  pool:
    max: 5
features: [search]
```

```ts
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](/docs/schemas) 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 [#what-tsc-reports]

Each of these lines is real tsc output for the base file above and this environment file:

```yaml title="config/app.production.config.yaml"
server:
  port: 8080
  hostname: 0.0.0.0
database:
  pool:
    max: twenty
features: [search, 42]
```

```txt
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:

```ts title="src/main.ts"
import config from '../config/app.config.yaml';

const port: string = config.server.port;
config.server.host = 'example.com';
console.log(config.cache);
```

```txt
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:

```txt
config/app.production.config.yaml(4,1): error tyfigo1001: Flow map in block collection must be sufficiently indented and end with a }
```

<Callout type="warn">
	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.
</Callout>

With [`loadConfig`](/docs/schemas), a config that can't match the schema's input type is reported at the call:

```ts title="src/main.ts"
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);
```

```txt
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 [#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`](/docs/schemas#loadconfig) with a schema, and use what it returns.

### Strict keys: no optional sections [#strict-keys-no-optional-sections]

An environment file can only override keys the base file has, so it can't add a section:

```yaml title="config/app.config.yaml"
name: app
```

```yaml title="config/app.production.config.yaml"
sentry:
  dsn: https://key@o1.ingest.sentry.io/1
```

```txt
config/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:

```yaml title="config/app.config.yaml"
name: app
sentry:
  enabled: false
  dsn: ''
```

```yaml title="config/app.production.config.yaml"
sentry:
  enabled: true
  dsn: https://key@o1.ingest.sentry.io/1
```

```ts
const 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 [#widened-values-no-literal-types]

`logLevel: info` is `string`, not `'info' | 'debug'`, because another environment file can set any string:

```ts
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:

```ts
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 [#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:

```ts
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:

```ts
const Config = z.object({ workers: z.coerce.number().int().positive() });
```

### Schema mismatches are reported at the call [#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](#what-tsc-reports)), not on the YAML line. When it runs, a value the schema rejects is reported at its line in the
file that set it:

```txt
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 >0
```

Keep 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 [#what-tsc-doesnt-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](/docs/schemas).
- Local files that are not in the program. Put your config folder in `include` so every layer is checked.

## Next.js [#nextjs]

`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](/docs/bundlers/next).
