diff --git a/README.md b/README.md index 5486393..99abf2d 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,59 @@ const { config } = defineConfig( ); ``` +### Cross-field validation + +Sometimes a single schema isn't enough — a field's validity may depend on another field's value. For these cases, add a `validate` function to any field. It runs after every per-field schema check has passed, and receives the fully-validated `config`: + +```typescript +import { defineConfig, validator } from 'figue'; +import * as v from 'valibot'; + +type Config = { + adapters: { id: string; url: string }[]; + defaultAdapterId: string; +}; + +const { config } = defineConfig({ + adapters: { + doc: 'Available resource adapters', + schema: v.array(v.object({ id: v.string(), url: v.string() })), + default: [], + env: 'ADAPTERS', + }, + defaultAdapterId: { + doc: 'Default adapter id (must reference one of `adapters[].id`)', + schema: v.string(), + default: '', + env: 'DEFAULT_ADAPTER_ID', + validate: validator(({ value, config }) => { + if (!config.adapters.some(a => a.id === value)) { + return `must match one of adapters[].id (got "${value}")`; + } + }), + }, +}); +``` + +Co-locating the rule with the field it belongs to keeps things readable, even when your config definition is split across modules. + +**Return contract** — the function can either throw or return: + +| Return value | Meaning | +| --- | --- | +| `void` / `undefined` / `true` | passes | +| `false` | generic `"Validation failed at "` issue | +| `string` | used as the issue message | +| `{ message, path? }` | a fully-formed issue (path defaults to the current field) | +| `ValidateIssue[]` | multiple issues from a single rule | +| `throw new Error(msg)` | caught; `error.message` becomes the issue | + +All issues from cross-field rules are aggregated into a single `ConfigValidationError`, just like schema errors. + +**Typing the `config` argument** — by default `config` is loosely typed (`any`) so you don't have to declare anything. When you want full IntelliSense on the cross-referenced fields, wrap your function with the `validator(...)` helper as shown above. It's a no-op at runtime, purely a type hint. + +**Note** — `validate` only runs once every field's schema has validated successfully, so you can trust that `config` is fully typed and coerced. + ## What's wrong with convict? Convict is meant to be used in node based environnement, it needs to have access to global variables that may may not be present in some environnement (like `process`, `global`), and it also imports `fs`. diff --git a/src/figue.test.ts b/src/figue.test.ts index 688646a..a91da35 100644 --- a/src/figue.test.ts +++ b/src/figue.test.ts @@ -1,7 +1,7 @@ import * as v from 'valibot'; -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { z } from 'zod'; -import { defineConfig } from './figue'; +import { defineConfig, validator } from './figue'; describe('figue tests', () => { describe('defineConfig', () => { @@ -252,4 +252,161 @@ describe('figue tests', () => { }); }); }); + + describe('per-field validate (cross-field rules)', () => { + test('a string return becomes the issue message', () => { + type Cfg = { adapters: { id: string; url: string }[]; defaultAdapterId: string }; + + expect(() => defineConfig({ + adapters: { + schema: v.array(v.object({ id: v.string(), url: v.string() })), + default: [{ id: 'a', url: 'https://a' }], + }, + defaultAdapterId: { + schema: v.string(), + default: 'missing', + env: 'DEFAULT_ADAPTER_ID', + validate: validator(({ value, config }) => { + if (!config.adapters.some(a => a.id === value)) { + return `must match one of adapters[].id (got "${value}")`; + } + }), + }, + })).toThrow('defaultAdapterId (DEFAULT_ADAPTER_ID): must match one of adapters[].id (got "missing")'); + }); + + test('a thrown error is caught and surfaced as an issue', () => { + expect(() => defineConfig({ + port: { + schema: z.number(), + default: 0, + validate: ({ value }) => { + if (value === 0) { + throw new Error('port must not be zero'); + } + }, + }, + })).toThrow('port: port must not be zero'); + }); + + test('an object return can override the path', () => { + expect(() => defineConfig({ + a: { + schema: z.string(), + default: 'x', + validate: () => ({ message: 'cross-field error', path: ['somewhere', 'else'] }), + }, + })).toThrow('somewhere.else: cross-field error'); + }); + + test('returning void/true passes', () => { + const { config } = defineConfig({ + a: { + schema: z.string(), + default: 'ok', + validate: () => undefined, + }, + b: { + schema: z.string(), + default: 'ok', + validate: () => true, + }, + }); + + expect(config).toEqual({ a: 'ok', b: 'ok' }); + }); + + test('returning false produces a generic issue', () => { + expect(() => defineConfig({ + a: { + schema: z.string(), + default: 'x', + validate: () => false, + }, + })).toThrow('a: Validation failed at a'); + }); + + test('validate is skipped when schema validation already failed', () => { + const validateSpy = vi.fn(); + + expect(() => defineConfig({ + a: { + schema: z.string(), + }, + b: { + schema: z.string(), + default: 'b', + validate: validateSpy, + }, + })).toThrow(); + + expect(validateSpy).not.toHaveBeenCalled(); + }); + + test('runs on nested leaves and receives the full config', () => { + type Cfg = { adapters: { id: string }[]; nested: { defaultId: string } }; + let seenConfig: unknown; + + expect(() => defineConfig({ + adapters: { + schema: v.array(v.object({ id: v.string() })), + default: [{ id: 'a' }], + }, + nested: { + defaultId: { + schema: v.string(), + default: 'nope', + validate: validator(({ value, config }) => { + seenConfig = config; + if (!config.adapters.some(a => a.id === value)) { + return 'unknown id'; + } + }), + }, + }, + })).toThrow('nested.defaultId: unknown id'); + + expect(seenConfig).toEqual({ + adapters: [{ id: 'a' }], + nested: { defaultId: 'nope' }, + }); + }); + + test('multiple validate issues are merged in a single error', () => { + expect(() => defineConfig({ + a: { + schema: z.string(), + default: 'x', + validate: () => 'first', + }, + b: { + schema: z.string(), + default: 'y', + validate: () => [{ message: 'second' }, { message: 'third' }], + }, + })).toThrow('a: first\nb: second\nb: third'); + }); + + test('validator helper provides typed config', () => { + type Config = { adapters: { id: string }[]; defaultAdapterId: string }; + + const { config } = defineConfig({ + adapters: { + schema: v.array(v.object({ id: v.string() })), + default: [{ id: 'a' }], + }, + defaultAdapterId: { + schema: v.string(), + default: 'a', + validate: validator(({ value, config }) => { + if (!config.adapters.some(a => a.id === value)) { + return 'unknown adapter id'; + } + }), + }, + }); + + expect(config).toEqual({ adapters: [{ id: 'a' }], defaultAdapterId: 'a' }); + }); + }); }); diff --git a/src/figue.ts b/src/figue.ts index f7e534b..ca1248e 100644 --- a/src/figue.ts +++ b/src/figue.ts @@ -1,4 +1,4 @@ -import type { ConfigDefinition, ConfigDefinitionElement, ConfigIssue, EnvRecord, InferSchemaType } from './figue.types'; +import type { ConfigDefinition, ConfigDefinitionElement, ConfigIssue, EnvRecord, InferSchemaType, ValidateFn, ValidateResult } from './figue.types'; import type { Falsy } from './types'; import { createConfigValidationError } from './figue.errors'; import { castArray, mapValues, mergeDeep } from './utils'; @@ -96,6 +96,75 @@ function isConfigDefinitionElement(config: unknown): config is ConfigDefinitionE ); } +export function validator(fn: ValidateFn): ValidateFn { + return fn; +} + +function normalizeValidateResult({ result, currentPath, definition }: { result: ValidateResult; currentPath: string[]; definition: ConfigDefinitionElement }): ConfigIssue[] { + if (result === undefined || result === null || result === true) { + return []; + } + + if (result === false) { + return [{ path: currentPath, message: `Validation failed at ${currentPath.join('.')}`, definition }]; + } + + if (typeof result === 'string') { + return [{ path: currentPath, message: result, definition }]; + } + + const issues = Array.isArray(result) ? result : [result]; + + return issues.map(issue => ({ + path: issue.path ?? currentPath, + message: issue.message, + definition, + })); +} + +function runValidateHooks({ + configDefinition, + config, + fullConfig, + path = [], +}: { + configDefinition: ConfigDefinition; + config: Record; + fullConfig: Record; + path?: string[]; +}): ConfigIssue[] { + const issues: ConfigIssue[] = []; + + for (const key in configDefinition) { + const currentPath = [...path, key]; + const definition = configDefinition[key] as ConfigDefinitionElement; + const value = config[key]; + + if (isConfigDefinitionElement(definition)) { + if (typeof definition.validate !== 'function') { + continue; + } + + try { + const result = definition.validate({ value, config: fullConfig }); + issues.push(...normalizeValidateResult({ result, currentPath, definition })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + issues.push({ path: currentPath, message, definition }); + } + } else if (typeof value === 'object' && value !== null) { + issues.push(...runValidateHooks({ + configDefinition: definition as ConfigDefinition, + config: value as Record, + fullConfig, + path: currentPath, + })); + } + } + + return issues; +} + function buildEnvConfig({ configDefinition, env }: { configDefinition: ConfigDefinition; env: EnvRecord }): Record { return mapValues(configDefinition, (config) => { if (isConfigDefinitionElement(config)) { @@ -204,5 +273,15 @@ export function defineConfig 0) { + throw createConfigValidationError({ issues: validateIssues }); + } + return { config: config as Config, env, envConfig }; } diff --git a/src/figue.types.ts b/src/figue.types.ts index 89af6fa..a7553c4 100644 --- a/src/figue.types.ts +++ b/src/figue.types.ts @@ -1,6 +1,15 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Expand } from './types'; +export type ValidateIssue = { + message: string; + path?: string[]; +}; + +export type ValidateResult = void | undefined | boolean | string | ValidateIssue | ValidateIssue[]; + +export type ValidateFn = (args: { value: Value; config: Config }) => ValidateResult; + export type ConfigDefinitionElement< T extends StandardSchemaV1 = StandardSchemaV1, Extra extends Record = Record, @@ -9,6 +18,7 @@ export type ConfigDefinitionElement< env?: string | string[]; doc?: string; default?: StandardSchemaV1.InferOutput; + validate?: ValidateFn>; } & Extra; export type ConfigDefinition = Record> = { diff --git a/src/index.ts b/src/index.ts index ea893a4..5a5d16d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { defineConfig } from './figue'; -export type { ConfigDefinition, ConfigDefinitionElement, ConfigDefinitionObject, EnvRecord } from './figue.types'; +export { defineConfig, validator } from './figue'; +export type { ConfigDefinition, ConfigDefinitionElement, ConfigDefinitionObject, EnvRecord, ValidateFn, ValidateIssue, ValidateResult } from './figue.types';