Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config>(({ 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 <path>"` 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<Config>(...)` 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`.
Expand Down
161 changes: 159 additions & 2 deletions src/figue.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<Cfg>(({ 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<Cfg>(({ 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<Config>(({ value, config }) => {
if (!config.adapters.some(a => a.id === value)) {
return 'unknown adapter id';
}
}),
},
});

expect(config).toEqual({ adapters: [{ id: 'a' }], defaultAdapterId: 'a' });
});
});
});
81 changes: 80 additions & 1 deletion src/figue.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -96,6 +96,75 @@ function isConfigDefinitionElement(config: unknown): config is ConfigDefinitionE
);
}

export function validator<Config = any, Value = any>(fn: ValidateFn<Value, Config>): ValidateFn<Value, Config> {
return fn;
}
Comment on lines +99 to +101

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<string, unknown>;
fullConfig: Record<string, unknown>;
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<string, unknown>,
fullConfig,
path: currentPath,
}));
}
}

return issues;
}

function buildEnvConfig({ configDefinition, env }: { configDefinition: ConfigDefinition; env: EnvRecord }): Record<string, unknown> {
return mapValues(configDefinition, (config) => {
if (isConfigDefinitionElement(config)) {
Expand Down Expand Up @@ -204,5 +273,15 @@ export function defineConfig<T extends ConfigDefinition, Config extends Record<s
throw createConfigValidationError({ issues });
}

const validateIssues = runValidateHooks({
configDefinition,
config,
fullConfig: config,
});

if (validateIssues.length > 0) {
throw createConfigValidationError({ issues: validateIssues });
}

return { config: config as Config, env, envConfig };
}
10 changes: 10 additions & 0 deletions src/figue.types.ts
Original file line number Diff line number Diff line change
@@ -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<Value = any, Config = any> = (args: { value: Value; config: Config }) => ValidateResult;

Comment thread
CorentinTh marked this conversation as resolved.
export type ConfigDefinitionElement<
T extends StandardSchemaV1 = StandardSchemaV1,
Extra extends Record<string, unknown> = Record<string, unknown>,
Expand All @@ -9,6 +18,7 @@ export type ConfigDefinitionElement<
env?: string | string[];
doc?: string;
default?: StandardSchemaV1.InferOutput<T>;
validate?: ValidateFn<StandardSchemaV1.InferOutput<T>>;
} & Extra;

export type ConfigDefinition<Extra extends Record<string, unknown> = Record<string, unknown>> = {
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading