From 8e6ef812e4540ae8a0bad2c99e97e66f8f840655 Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 24 Aug 2026 13:11:09 -0700 Subject: [PATCH 1/2] [config] derive get() key and value types from the schema --- .../docs/browser/functions/createConfig.md | 19 ++- packages/config/docs/config/classes/Config.md | 44 ++++-- .../config/variables/CONFIG_GLOBAL_NAME.md | 2 +- .../docs/node/functions/createConfig.md | 19 ++- .../docs/node/functions/getBrowserDefine.md | 12 +- .../docs/node/interfaces/ConfigEnvVars.md | 2 +- packages/config/src/browser.mts | 9 +- packages/config/src/browser.test.mts | 18 +++ packages/config/src/config.ts | 16 +- packages/config/src/node.test.ts | 58 ++++++- packages/config/src/node.ts | 11 +- packages/config/src/types.ts | 143 +++++++++++++++++- 12 files changed, 310 insertions(+), 43 deletions(-) diff --git a/packages/config/docs/browser/functions/createConfig.md b/packages/config/docs/browser/functions/createConfig.md index 0ed2e0a..84969c8 100644 --- a/packages/config/docs/browser/functions/createConfig.md +++ b/packages/config/docs/browser/functions/createConfig.md @@ -4,24 +4,30 @@ # Function: createConfig() -> **createConfig**(`schema`): [`Config`](../../config/classes/Config.md) +> **createConfig**\<`S`\>(`schema`): [`Config`](../../config/classes/Config.md)\<`S`\> -Defined in: [browser.mts:47](/packages/config/src/browser.mts#L47) +Defined in: [browser.mts:50](/packages/config/src/browser.mts#L50) Builds a [Config](../../config/classes/Config.md) instance for use in the browser. Settings are sourced from the `public: true` subset of a Node-side config, baked in at build time via `getBrowserDefine()` and your bundler - this entry point has no dependency on any Node.js-only API. +## Type Parameters + +### S + +`S` *extends* [`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md) + ## Parameters ### schema -[`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md) +`S` ## Returns -[`Config`](../../config/classes/Config.md) +[`Config`](../../config/classes/Config.md)\<`S`\> ## Example @@ -37,7 +43,10 @@ export const config = createConfig(schema); // elsewhere.ts import { config } from './config.js'; -config.get('app.name'); +config.get('app.name'); // string - typed from the schema above ``` +The schema's shape is captured as `S`, so `config.get()` accepts only the +keys it declares and returns the type each one holds. + See `getBrowserDefine()` in `node.ts` for the bundler side of this. diff --git a/packages/config/docs/config/classes/Config.md b/packages/config/docs/config/classes/Config.md index efe19ad..6b0d0a8 100644 --- a/packages/config/docs/config/classes/Config.md +++ b/packages/config/docs/config/classes/Config.md @@ -2,9 +2,9 @@ *** -# Class: Config +# Class: Config\ -Defined in: [config.ts:46](/packages/config/src/config.ts#L46) +Defined in: [config.ts:54](/packages/config/src/config.ts#L54) Schema-driven configuration store. Given a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md) and a map of environment variables, hydrates each leaf setting's value from the @@ -30,13 +30,25 @@ const config = new Config({ config.get('app.name'); // 'My App', or the value of `process.env.MY_APP_NAME`, if set ``` +`S` carries the shape of the schema so `get()` can resolve a key to the +type that key holds. It is inferred by `createConfig()` in +`node.ts`/`browser.mts`; constructing a `Config` directly leaves it at the +default, where keys are plain strings and values the full +SettingsValue union. + +## Type Parameters + +### S + +`S` *extends* [`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md) = [`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md) + ## Constructors ### Constructor -> **new Config**(`__namedParameters?`): `Config` +> **new Config**\<`S`\>(`__namedParameters?`): `Config`\<`S`\> -Defined in: [config.ts:49](/packages/config/src/config.ts#L49) +Defined in: [config.ts:57](/packages/config/src/config.ts#L57) #### Parameters @@ -46,7 +58,7 @@ Defined in: [config.ts:49](/packages/config/src/config.ts#L49) #### Returns -`Config` +`Config`\<`S`\> ## Properties @@ -54,27 +66,33 @@ Defined in: [config.ts:49](/packages/config/src/config.ts#L49) > **settings**: `Settings` -Defined in: [config.ts:47](/packages/config/src/config.ts#L47) +Defined in: [config.ts:55](/packages/config/src/config.ts#L55) ## Methods ### get() -> **get**(`key`): `SettingsValue` +> **get**\<`K`\>(`key`): `SettingsValueAt`\<`S`, `K`\> -Defined in: [config.ts:58](/packages/config/src/config.ts#L58) +Defined in: [config.ts:66](/packages/config/src/config.ts#L66) Looks up a single setting's hydrated value by its dot-delimited key. +#### Type Parameters + +##### K + +`K` *extends* `string` + #### Parameters ##### key -`string` +`K` #### Returns -`SettingsValue` +`SettingsValueAt`\<`S`, `K`\> #### Throws @@ -86,7 +104,7 @@ if `key` isn't present in the hydrated schema > **getPublicEnvVars**(): [`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md) -Defined in: [config.ts:87](/packages/config/src/config.ts#L87) +Defined in: [config.ts:95](/packages/config/src/config.ts#L95) Returns every public setting's value keyed by its *environment variable name* rather than its schema path. @@ -101,7 +119,7 @@ name* rather than its schema path. > **getPublicSettings**(): `PublicSettings` -Defined in: [config.ts:69](/packages/config/src/config.ts#L69) +Defined in: [config.ts:77](/packages/config/src/config.ts#L77) Returns every hydrated setting marked `public: true`, keyed by dot-delimited path. @@ -115,7 +133,7 @@ Returns every hydrated setting marked `public: true`, keyed by dot-delimited pat > **hydrate**(`data`, `env?`): `Settings` -Defined in: [config.ts:110](/packages/config/src/config.ts#L110) +Defined in: [config.ts:118](/packages/config/src/config.ts#L118) Walks a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md), resolving each leaf's format, coercing/validating its value from `env` (or its default), and diff --git a/packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md b/packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md index 176f8fd..fa0dcd4 100644 --- a/packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md +++ b/packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md @@ -6,7 +6,7 @@ > `const` **CONFIG\_GLOBAL\_NAME**: `"__BUST_CONFIG__"` = `'__BUST_CONFIG__'` -Defined in: [config.ts:20](/packages/config/src/config.ts#L20) +Defined in: [config.ts:22](/packages/config/src/config.ts#L22) The name of the global variable `@bust/config` expects to find settings stored in when runnning in a browser. diff --git a/packages/config/docs/node/functions/createConfig.md b/packages/config/docs/node/functions/createConfig.md index 2b3ec91..4253f60 100644 --- a/packages/config/docs/node/functions/createConfig.md +++ b/packages/config/docs/node/functions/createConfig.md @@ -4,24 +4,30 @@ # Function: createConfig() -> **createConfig**(`schema`): [`Config`](../../config/classes/Config.md) +> **createConfig**\<`S`\>(`schema`): [`Config`](../../config/classes/Config.md)\<`S`\> -Defined in: [node.ts:36](/packages/config/src/node.ts#L36) +Defined in: [node.ts:39](/packages/config/src/node.ts#L39) Builds a [Config](../../config/classes/Config.md) for use in Node.js: a local `.env` file (if present in the current working directory) is loaded into `process.env` before `schema` is hydrated, so local development values can live in a git-ignored `.env` file instead of real environment variables. +## Type Parameters + +### S + +`S` *extends* [`SettingsSchemaTree`](../interfaces/SettingsSchemaTree.md) + ## Parameters ### schema -[`SettingsSchemaTree`](../interfaces/SettingsSchemaTree.md) +`S` ## Returns -[`Config`](../../config/classes/Config.md) +[`Config`](../../config/classes/Config.md)\<`S`\> ## Example @@ -41,5 +47,8 @@ export const config = createConfig({ // elsewhere.ts import { config } from './config.ts'; -config.get('app.name'); +config.get('app.name'); // string - typed from the schema above ``` + +The schema's shape is captured as `S`, so `config.get()` accepts only the +keys it declares and returns the type each one holds. diff --git a/packages/config/docs/node/functions/getBrowserDefine.md b/packages/config/docs/node/functions/getBrowserDefine.md index ab20196..f296a7a 100644 --- a/packages/config/docs/node/functions/getBrowserDefine.md +++ b/packages/config/docs/node/functions/getBrowserDefine.md @@ -4,9 +4,9 @@ # Function: getBrowserDefine() -> **getBrowserDefine**(`config`): `Record`\<`string`, [`ConfigEnvVars`](../interfaces/ConfigEnvVars.md)\> +> **getBrowserDefine**\<`S`\>(`config`): `Record`\<`string`, [`ConfigEnvVars`](../interfaces/ConfigEnvVars.md)\> -Defined in: [node.ts:59](/packages/config/src/node.ts#L59) +Defined in: [node.ts:62](/packages/config/src/node.ts#L62) Produces a Vite `define` entry that exposes only `config`'s `public: true` settings to a browser build - `createConfig()` in the browser reads this @@ -14,11 +14,17 @@ same blob back out at runtime via [CONFIG\_GLOBAL\_NAME](../../config/variables/ leave the Node process beyond what `getPublicEnvVars()` already returns, so there's no separate `VITE_`-prefixed env var to keep in sync with `schema`. +## Type Parameters + +### S + +`S` *extends* [`SettingsSchemaTree`](../interfaces/SettingsSchemaTree.md) + ## Parameters ### config -[`Config`](../../config/classes/Config.md) +[`Config`](../../config/classes/Config.md)\<`S`\> ## Returns diff --git a/packages/config/docs/node/interfaces/ConfigEnvVars.md b/packages/config/docs/node/interfaces/ConfigEnvVars.md index a8674fe..d9a2758 100644 --- a/packages/config/docs/node/interfaces/ConfigEnvVars.md +++ b/packages/config/docs/node/interfaces/ConfigEnvVars.md @@ -4,7 +4,7 @@ # Interface: ConfigEnvVars -Defined in: [types.ts:104](/packages/config/src/types.ts#L104) +Defined in: [types.ts:241](/packages/config/src/types.ts#L241) A map of environment variable names to values. Values are typically raw strings (as they'd come from `process.env`), but pre-coerced values are diff --git a/packages/config/src/browser.mts b/packages/config/src/browser.mts index 3f5af2b..35d0321 100644 --- a/packages/config/src/browser.mts +++ b/packages/config/src/browser.mts @@ -39,11 +39,14 @@ export function getBrowserEnv(): ConfigEnvVars { * // elsewhere.ts * import { config } from './config.js'; * - * config.get('app.name'); + * config.get('app.name'); // string - typed from the schema above * ``` * + * The schema's shape is captured as `S`, so `config.get()` accepts only the + * keys it declares and returns the type each one holds. + * * See `getBrowserDefine()` in `node.ts` for the bundler side of this. */ -export function createConfig(schema: SettingsSchemaTree): Config { - return new Config({ schema, env: getBrowserEnv() }); +export function createConfig(schema: S): Config { + return new Config({ schema, env: getBrowserEnv() }); } diff --git a/packages/config/src/browser.test.mts b/packages/config/src/browser.test.mts index 708e7ae..162f9dc 100644 --- a/packages/config/src/browser.test.mts +++ b/packages/config/src/browser.test.mts @@ -31,6 +31,24 @@ describe('@bust/config/browser', () => { }); }); + describe('Schema-derived types', () => { + it('Types keys and values from the schema, same as the Node entry point', () => { + const config = createConfig({ + app: { + name: { default: 'My App', env: 'MY_APP_NAME' }, + mode: { default: 'light', format: ['light', 'dark'] }, + }, + }); + const name: string = config.get('app.name'); + const mode: 'light' | 'dark' = config.get('app.mode'); + + // @ts-expect-error - 'app.nmae' is a typo, not a declared key + assert.throws(() => config.get('app.nmae')); + assert.strictEqual(name, 'My App'); + assert.strictEqual(mode, 'light'); + }); + }); + describe('getBrowserEnv', () => { it('Falls back to an empty object when nothing was baked in', () => { assert.deepEqual(getBrowserEnv(), {}); diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 92c03f2..6b2d9e0 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -1,6 +1,8 @@ import type { SettingsKey, + SettingsKeyOf, SettingsValue, + SettingsValueAt, SettingsFormat, SettingsFormatName, SettingsSpecInput, @@ -42,8 +44,14 @@ export const CONFIG_GLOBAL_NAME = '__BUST_CONFIG__'; * * config.get('app.name'); // 'My App', or the value of `process.env.MY_APP_NAME`, if set * ``` + * + * `S` carries the shape of the schema so `get()` can resolve a key to the + * type that key holds. It is inferred by `createConfig()` in + * `node.ts`/`browser.mts`; constructing a `Config` directly leaves it at the + * default, where keys are plain strings and values the full + * {@link SettingsValue} union. */ -export class Config { +export class Config { settings: Settings; constructor({ schema = {}, env = {} }: ConfigOptions = {}) { @@ -55,14 +63,14 @@ export class Config { * * @throws if `key` isn't present in the hydrated schema */ - get(key: SettingsKey): SettingsValue { + get & string>(key: K): SettingsValueAt { const spec = this.settings.get(key); if (!spec) { throw new Error(`'${key}' is not available - please ensure you've set it`); } - return spec.value; + return spec.value as SettingsValueAt; } /** Returns every hydrated setting marked `public: true`, keyed by dot-delimited path. */ @@ -200,7 +208,7 @@ function format(x: SettingsValue | undefined, key: SettingsKey, spec: SettingsSp const validators: Record = { enum: function(key, spec) { - const allowed = spec.format as string[]; + const allowed = spec.format as readonly string[]; if (!allowed.includes(spec.value as string)) { throw new Error(`'${key}': must be one of ${allowed.join('|')}`); diff --git a/packages/config/src/node.test.ts b/packages/config/src/node.test.ts index c383fe9..f57a333 100644 --- a/packages/config/src/node.test.ts +++ b/packages/config/src/node.test.ts @@ -3,7 +3,8 @@ import { strict as assert } from 'node:assert'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { createConfig, getBrowserDefine, CONFIG_GLOBAL_NAME } from './node.js'; +import type { SettingsValue } from './types.js'; +import { Config, createConfig, getBrowserDefine, CONFIG_GLOBAL_NAME } from './node.js'; describe('@bust/config/node', () => { @@ -21,6 +22,61 @@ describe('@bust/config/node', () => { }); }); + describe('Schema-derived types', () => { + // an inline schema keeps its literal types - a `const` type parameter + // only preserves them for the expression at the call site, so a schema + // hoisted into its own variable needs `as const` to type its enums + const typed = createConfig({ + app: { + name: { default: 'My App', env: 'MY_APP_NAME' }, + env: { default: 'development', format: ['development', 'staging', 'production'] }, + url: { default: 'http://example.com', format: 'url' }, + port: { default: 3000, format: 'nat' }, + debug: { default: false }, + hosts: { default: [], format: 'array' }, + owners: { default: ['ada@example.com'], format: 'array' }, + pattern: { default: /^ok$/ }, + limits: { default: { max: 10 }, format: 'object' }, + }, + }); + + it('Resolves each setting to the type its format implies', () => { + const name: string = typed.get('app.name'); + const env: 'development' | 'staging' | 'production' = typed.get('app.env'); + const url: string = typed.get('app.url'); + const port: number = typed.get('app.port'); + const debug: boolean = typed.get('app.debug'); + const hosts: string[] = typed.get('app.hosts'); + const owners: string[] = typed.get('app.owners'); + const pattern: RegExp = typed.get('app.pattern'); + const limits: Record = typed.get('app.limits'); + + assert.strictEqual(name, 'My App'); + assert.strictEqual(env, 'development'); + assert.strictEqual(url, 'http://example.com'); + assert.strictEqual(port, 3000); + assert.strictEqual(debug, false); + assert.deepEqual(hosts, []); + assert.deepEqual(owners, ['ada@example.com']); + assert.deepEqual(pattern, /^ok$/); + assert.deepEqual(limits, { max: 10 }); + }); + + it('Rejects keys the schema does not declare', () => { + // @ts-expect-error - 'app.nmae' is a typo, not a declared key + assert.throws(() => typed.get('app.nmae')); + // @ts-expect-error - 'app' is a branch, not a setting + assert.throws(() => typed.get('app')); + }); + + it('Falls back to loose keys and values when the schema shape is unknown', () => { + const loose: Config = new Config({ schema, env: {} }); + const value: SettingsValue = loose.get('app.name'); + + assert.strictEqual(value, 'My App'); + }); + }); + describe('getBrowserDefine()', () => { it('Includes public settings in a bundler-friendly `define` object', async () => { const config = createConfig(schema); diff --git a/packages/config/src/node.ts b/packages/config/src/node.ts index f179f12..bb027ef 100644 --- a/packages/config/src/node.ts +++ b/packages/config/src/node.ts @@ -30,11 +30,14 @@ dotenv.config({ quiet: true }); // silence log spam / ads from dotenv v17 | http * // elsewhere.ts * import { config } from './config.ts'; * - * config.get('app.name'); + * config.get('app.name'); // string - typed from the schema above * ``` + * + * The schema's shape is captured as `S`, so `config.get()` accepts only the + * keys it declares and returns the type each one holds. */ -export function createConfig(schema: SettingsSchemaTree): Config { - return new Config({ schema, env: process.env }); +export function createConfig(schema: S): Config { + return new Config({ schema, env: process.env }); } /** @@ -56,6 +59,6 @@ export function createConfig(schema: SettingsSchemaTree): Config { * }); * ``` */ -export function getBrowserDefine(config: Config): Record { +export function getBrowserDefine(config: Config): Record { return { [CONFIG_GLOBAL_NAME]: config.getPublicEnvVars() }; } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index bf03eaf..dcc5060 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -8,7 +8,7 @@ export type SettingsKey = string; export type SettingsPrimitive = string | number | boolean | null; /** Any value a setting can hold once it's been coerced to its target format. */ -export type SettingsValue = SettingsPrimitive | SettingsPrimitive[] | RegExp | Record; +export type SettingsValue = SettingsPrimitive | readonly SettingsPrimitive[] | RegExp | Record; /** The built-in coercion/validation formats a setting can declare. */ export type SettingsFormatName = @@ -27,7 +27,7 @@ export type SettingsFormatName = * array of allowed string values (treated as an enum), or `undefined` to * infer the format from the JS type of `default`. */ -export type SettingsFormat = SettingsFormatName | string[] | undefined; +export type SettingsFormat = SettingsFormatName | readonly string[] | undefined; /** Human-readable description of a setting, surfaced in generated docs. */ export type SettingsDoc = string | undefined; @@ -85,9 +85,146 @@ export interface SettingsSpec extends Omit { * ``` */ export interface SettingsSchemaTree { - [key: string]: SettingsSpecInput | SettingsSchemaTree; + readonly [key: string]: SettingsSpecInput | SettingsSchemaTree; } +/** + * Every dot-delimited key a schema declares, as a union of string literals - + * `{ app: { name: { default: 'My App' } } }` yields `'app.name'`. + */ +export type SettingsPath = { + [K in keyof S & string]: S[K] extends { default: unknown } + ? K + : `${K}.${SettingsPath}`; +}[keyof S & string]; + +/** + * The keys `Config.get()` accepts. A schema whose shape is known resolves to + * its {@link SettingsPath} union, so typos are compile errors; one typed + * loosely enough to carry a string index signature falls back to + * {@link SettingsKey}, which is what an untyped `new Config()` gets. + */ +export type SettingsKeyOf = string extends keyof S + ? SettingsKey + : SettingsPath; + +/** The {@link SettingsSpecInput} a dot-delimited key resolves to within a schema. */ +export type SettingsSpecAt = P extends `${infer Head}.${infer Rest}` + ? SettingsSpecBelow + : SettingsSpecNamed; + +/** Descends one branch of a dot-delimited path on behalf of {@link SettingsSpecAt}. */ +export type SettingsSpecBelow = Head extends keyof S + ? SettingsSpecAt + : never; + +/** Resolves the final, undotted segment of a path on behalf of {@link SettingsSpecAt}. */ +export type SettingsSpecNamed = K extends keyof S + ? S[K] + : never; + +/** The literal types a `default` can declare that widen back to a base primitive. */ +export type SettingsWidenable = string | number | boolean; + +/** + * Widens a literal inferred from a schema's `default` back to its base + * primitive - a setting declared `default: 'My App'` holds any `string`, not + * that one literal. Enum formats are the deliberate exception; see + * {@link SettingsValueOf}. + * + * Each branch tests `T` independently and contributes `never` when it doesn't + * apply, so the union collapses to the one that does. + */ +export type SettingsWidened = + | (T extends string ? string : never) + | (T extends number ? number : never) + | (T extends boolean ? boolean : never) + | (T extends SettingsWidenable ? never : T); + +/** + * The element type behind an `array` setting, inferred from `default` where + * it says something. + */ +export type SettingsArrayValue = Spec extends { default: readonly (infer Element)[] } + ? SettingsArrayOf + : string[]; + +/** + * Falls back to `string[]` for an empty default, which says nothing about its + * elements - the value can always arrive as a comma-delimited environment + * variable, which `Config.hydrate()` splits into strings. + */ +export type SettingsArrayOf = [Element] extends [never] + ? string[] + : SettingsWidened[]; + +/** The value type a spec's `default` implies when it declares no explicit format. */ +export type SettingsDefaultValue = Spec extends { default: infer Default } + ? SettingsInferredValue + : SettingsValue; + +/** + * Reads a value type off a `default` on behalf of {@link SettingsDefaultValue}. + * A plain-object default resolves to `Record` rather than its + * own shape: an environment variable supplies that value as JSON, so nothing + * guarantees the keys survive. + */ +export type SettingsInferredValue = + | (Default extends RegExp ? RegExp : never) + | (Default extends readonly unknown[] ? SettingsArrayValue : never) + | (Default extends Record ? Record : never) + | (Default extends SettingsWidenable | null ? SettingsWidened : never); + +/** The type each built-in format name resolves to. */ +export interface SettingsFormatValues { + int: number; + nat: number; + number: number; + boolean: boolean; + string: string; + url: string; + object: Record; + regexp: RegExp; +} + +/** + * The type a single setting holds once hydrated, derived from its declared + * `format` and falling back to its `default` when it declares none. + */ +export type SettingsValueOf = [SettingsFormatOf] extends [never] + ? SettingsDefaultValue + : SettingsFormatValue>; + +/** A spec's declared format, or `never` when it declares none. */ +export type SettingsFormatOf = Spec extends { format: infer Format } + ? Exclude + : never; + +/** + * The type a declared `format` implies. `array` is the one format whose value + * depends on the spec around it, and an array of allowed values resolves to + * that union - so `format: ['a', 'b']` reads back as `'a' | 'b'` rather than + * `string`. A format too wide to identify (a schema hoisted into its own + * variable widens `'url'` to `string`) falls back to {@link SettingsValue}. + * + * Each branch tests `Format` independently and contributes `never` when it + * doesn't apply, so the union collapses to the one that does. + */ +export type SettingsFormatValue = + | (Format extends keyof SettingsFormatValues ? SettingsFormatValues[Format] : never) + | (Format extends readonly string[] ? Format[number] : never) + | (Format extends 'array' ? SettingsArrayValue : never) + | (Format extends SettingsFormatName | readonly string[] ? never : SettingsValue); + +/** + * The type `Config.get()` returns for a given key. Mirrors + * {@link SettingsKeyOf}: a schema with a string index signature has no key to + * resolve against, so it falls back to the full {@link SettingsValue} union. + */ +export type SettingsValueAt = string extends keyof S + ? SettingsValue + : SettingsValueOf>; + /** The flattened, hydrated form of a {@link SettingsSchemaTree}: dot-delimited key to resolved spec. */ export type Settings = Map; From f5e7db714d06a6a80908eec56d14cdaccdfa03ce Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 24 Aug 2026 13:14:10 -0700 Subject: [PATCH 2/2] document the no-nested-conditional-types convention --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0ffd9b4..5ffd560 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,12 @@ import { formatLabel } from './lib/format.js'; - Keep comments minimal and free of unnecessary commentary — capture the key details as concisely as possible. - Todo comments are formatted like `TODO (busticated): `, using the handle of the person the work is for. `npm run todo` lists every TODO in the repo, regardless of format. +**Types.** Conditional types are never nested — `no-nested-ternary` matches `ConditionalExpression` only, so lint cannot catch this. Keep each conditional type to a single level and reach for one of these instead: + +- A union of independent branches, where each tests the same input and contributes `never` when it doesn't apply, so the union collapses to the one that matches — see `SettingsWidened` in [packages/config/src/types.ts](packages/config/src/types.ts). +- An interface keyed by the discriminating literal, looked up with `T extends keyof Map ? Map[T] : ...` — see `SettingsFormatValues` in the same file. +- A named helper type per level, delegating rather than nesting — see `SettingsSpecAt`. + --- ## Testing