From cf916a09693a8c690cab28c035c8c17b2bade877 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 17:49:11 +0400 Subject: [PATCH 1/4] feat(types): Added code-aware violation inference --- README.md | 2 + docs/en/00-index.md | 1 + docs/en/08-violation-code-types.md | 241 +++++++++++++++++++ docs/ru/00-index.md | 1 + docs/ru/08-violation-code-types.md | 241 +++++++++++++++++++ docs/ru/README.md | 2 + src/assert.ts | 71 ++++-- src/assertions.ts | 56 +++-- src/combinators.ts | 56 +++-- src/index.ts | 30 ++- tests/combinators.test.ts | 20 +- tests/metadata.test-d.ts | 10 +- tests/validate.test.ts | 30 ++- tests/validate.tuple.test-d.ts | 25 ++ tests/violation-codes.test-d.ts | 92 +++++++ tests/violations.test-d.ts | 2 +- tests/violations.test.ts | 20 +- types/index.d.ts | 369 +++++++++++++++++++++++++---- 18 files changed, 1149 insertions(+), 120 deletions(-) create mode 100644 docs/en/08-violation-code-types.md create mode 100644 docs/ru/08-violation-code-types.md create mode 100644 tests/violation-codes.test-d.ts diff --git a/README.md b/README.md index 6aef801..b16c9ea 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ const node = describe(registration) Detailed guides: - [Metadata And Introspection Guide](./docs/en/02-metadata-and-introspection.md) +- [Violation Code Types Guide](./docs/en/08-violation-code-types.md) ## Mental Model @@ -305,6 +306,7 @@ const emailErrors = errors.at(['profile', 'email']) Detailed guides: - [Violations Guide](./docs/en/03-violations.md) +- [Violation Code Types Guide](./docs/en/08-violation-code-types.md) ## JSON Schema Export diff --git a/docs/en/00-index.md b/docs/en/00-index.md index 0e93880..7b3eb21 100644 --- a/docs/en/00-index.md +++ b/docs/en/00-index.md @@ -7,6 +7,7 @@ - [Public API](./05-public-api.md) - Overview of root exports, subpath exports, validation result shape, and how the package surface is split. - [Common Recipes](./06-common-recipes.md) - Task-oriented recipes for choosing the right layer, validating payloads, reusing shapes, shaping UI errors, and exporting schemas safely. - [AI Reference](./07-ai-reference.md) - Compact contract summary for AI agents, tooling, and quick lookup of stable library semantics. +- [Violation Code Types](./08-violation-code-types.md) - Detailed guide to `ViolationCodeRegistry`, `ViolationCode`, literal code preservation in descriptors, and external registry augmentation. ## Translations diff --git a/docs/en/08-violation-code-types.md b/docs/en/08-violation-code-types.md new file mode 100644 index 0000000..eb6dfe8 --- /dev/null +++ b/docs/en/08-violation-code-types.md @@ -0,0 +1,241 @@ +# Violation Code Types + +[Documentation index](./00-index.md) +[Russian translation](../ru/08-violation-code-types.md) + +`@modulify/validator` exposes two related layers around machine-readable codes: + +- exact literal codes in assertion descriptors and structured violations; +- an extensible global registry that can be used to derive a project-wide union of known codes. + +This guide explains when each layer is useful, how they work together, and how to extend them safely. + +## Quick Start + +The public entrypoints are: + +- `ViolationCodeEntry` - a compact contract record for a known code; +- `ViolationCodeRegistry` - an interface that collects known codes; +- `ViolationCode` - a union extracted from `keyof ViolationCodeRegistry`; +- `ViolationArgs`, `ViolationKindOf`, and `ViolationNameOf` - code-driven utility types. + +The package ships built-in keys for its own built-in violations, for example: + +- `'type.string'` +- `'length.min'` +- `'shape.unknown-key'` +- `'runtime.rejection'` + +So the following works out of the box: + +```typescript +import type { ViolationCode } from '@modulify/validator' + +const code: ViolationCode = 'type.string' +``` + +## Exact Codes From `describe(...)` + +Built-in assertions now preserve their exact code literals in introspection. + +```typescript +import { + describe, + hasLength, + isString, +} from '@modulify/validator' + +const stringDescriptor = describe(isString) +const lengthDescriptor = describe(hasLength({ min: 3 })) +``` + +In TypeScript this means: + +- `stringDescriptor.code` is typed as `'type.string'`; +- `stringDescriptor.args` is typed as `[]`; +- `lengthDescriptor.code` is typed as `'length.unsupported-type'`; +- `lengthDescriptor.constraints[number].code` is typed as a concrete length-code union instead of plain `string`. + +This is useful when adapters inspect descriptors and want code-aware branching without hand-written casts. + +## What The Global Registry Solves + +Exact literals on individual values are good for local introspection. + +The global registry solves a different problem: extracting one reusable union for the whole app. + +```typescript +import type { ViolationCode } from '@modulify/validator' + +type AppViolationCode = ViolationCode +``` + +That union can be reused in: + +- message dictionaries; +- analytics payload contracts; +- API error envelopes; +- UI error-state mappers; +- shared helper utilities. + +## Extending `ViolationCodeRegistry` + +The registry is designed for module augmentation and now stores small code contracts. + +```typescript +import type { ViolationCodeEntry } from '@modulify/validator' +import '@modulify/validator' + +declare module '@modulify/validator' { + interface ViolationCodeRegistry { + 'user.email.taken': ViolationCodeEntry<'validator', 'user', readonly []>; + 'profile.password.mismatch': ViolationCodeEntry<'validator', 'shape', readonly []>; + } +} +``` + +After that: + +```typescript +import type { ViolationCode } from '@modulify/validator' + +const codeA: ViolationCode = 'user.email.taken' +const codeB: ViolationCode = 'profile.password.mismatch' +``` + +This lets you define project-specific codes once and reuse the extracted union everywhere else. + +If you still have older augmentations that use `never`, they continue to contribute to `ViolationCode`, but they fall back to generic `kind` / `name` / `args` typing until you migrate them to `ViolationCodeEntry`. + +## Deriving Related Types From A Code + +Once a code is registered with a contract entry, it becomes a key into the related type information. + +```typescript +import type { + ViolationArgs, + ViolationKindOf, + ViolationNameOf, + ViolationSubject, +} from '@modulify/validator' + +type PasswordArgs = ViolationArgs<'profile.password.mismatch'> +type PasswordKind = ViolationKindOf<'profile.password.mismatch'> +type PasswordName = ViolationNameOf<'profile.password.mismatch'> +type PasswordSubject = ViolationSubject<'profile.password.mismatch'> +``` + +That means: + +- `PasswordArgs` becomes `readonly []`; +- `PasswordKind` becomes `'validator'`; +- `PasswordName` becomes `'shape'`; +- `PasswordSubject` gets the matching `kind`, `name`, `code`, and `args`. + +## Using Augmented Codes In Custom Assertions + +Custom assertions can keep their own explicit code literals. + +```typescript +import { assert } from '@modulify/validator/assertions' + +const isAvailableEmail = assert( + (value: unknown): value is string => typeof value === 'string' && value.includes('@'), + { + name: 'isAvailableEmail', + bail: true, + code: 'user.email.taken', + } +) +``` + +Then `describe(isAvailableEmail).code` is typed as `'user.email.taken'`. + +This part does not depend on the global union extraction. The literal is preserved directly from the assertion definition. + +## Using Augmented Codes In Shape Refinements + +The same idea applies to object-level refinement issues. + +```typescript +import type { ObjectShapeRefinementIssue } from '@modulify/validator' +import { + isEmail, + isString, + shape, +} from '@modulify/validator' + +const signUpForm = shape({ + email: [isString, isEmail], + password: isString, + confirmation: shape({ + password: isString, + }), +}).refine(value => { + if (value.password === value.confirmation.password) { + return [] + } + + return [{ + path: ['confirmation', 'password'], + code: 'profile.password.mismatch', + args: [], + }] satisfies ObjectShapeRefinementIssue<'profile.password.mismatch'> +}) +``` + +That keeps the refinement code aligned with the same registry-backed union used elsewhere. + +## A Practical Pattern For App-Level Mappers + +Many consumers want a small helper layer that maps codes into rendering or transport concerns. + +```typescript +import type { + Violation, + ViolationCode, +} from '@modulify/validator' + +const labels: Partial> = { + 'type.string': 'Expected a string', + 'length.min': 'Value is too short', + 'user.email.taken': 'Email is already taken', +} + +function toLabel(violation: Violation) { + return labels[violation.violates.code as ViolationCode] ?? violation.violates.code +} +``` + +You do not need to force every possible code into one giant exhaustive map. `Partial>` is often the most practical option. + +## Built-In Union vs Explicit Literal Preservation + +These two behaviors are complementary: + +- built-in and augmented codes appear in the reusable `ViolationCode` union; +- explicit custom literals are preserved directly where values are created, such as `assert(...)` or typed refinement issues. + +That distinction is important. + +If you define a custom literal but do not augment `ViolationCodeRegistry`: + +- local descriptor and violation values can still carry that exact literal; +- the global `ViolationCode` union will not include it yet. + +If you augment `ViolationCodeRegistry` with `never` instead of `ViolationCodeEntry`: + +- the global `ViolationCode` union will include the code; +- the code still falls back to generic `kind`, `name`, and `args` typing. + +So the usual recommendation is: + +1. define the custom code where the violation is produced; +2. add it to `ViolationCodeRegistry`; +3. reuse `ViolationCode` in adapters and app-level helper types. + +## Related APIs + +- [Metadata And Introspection](./02-metadata-and-introspection.md) +- [Violations](./03-violations.md) +- [Public API](./05-public-api.md) diff --git a/docs/ru/00-index.md b/docs/ru/00-index.md index 9c3b6d6..5dabe3f 100644 --- a/docs/ru/00-index.md +++ b/docs/ru/00-index.md @@ -8,6 +8,7 @@ - [Публичный API](./05-public-api.md) - Обзор публичных точек входа, subpath exports и результата валидации. - [Практические рецепты](./06-common-recipes.md) - Практические сценарии по выбору слоя, валидации payload, переиспользованию shape, построению состояния ошибок в UI и безопасному экспорту схемы. - [Справка для AI](./07-ai-reference.md) - Краткое описание контракта для AI agents, инструментов и быстрого поиска стабильной семантики библиотеки. +- [Типы кодов нарушений](./08-violation-code-types.md) - Подробное руководство по `ViolationCodeRegistry`, `ViolationCode`, сохранению literal-кодов в descriptors и внешнему расширению реестра. ## Переводы diff --git a/docs/ru/08-violation-code-types.md b/docs/ru/08-violation-code-types.md new file mode 100644 index 0000000..5d1235d --- /dev/null +++ b/docs/ru/08-violation-code-types.md @@ -0,0 +1,241 @@ +# Типы кодов нарушений + +[Индекс документации](./00-index.md) +[English version](../en/08-violation-code-types.md) + +`@modulify/validator` теперь даёт два связанных слоя для машиночитаемых кодов: + +- точные literal-коды в assertion descriptors и structured violations; +- расширяемый глобальный реестр, из которого можно получить project-wide union известных кодов. + +В этом руководстве разобрано, когда полезен каждый из слоёв, как они сочетаются и как безопасно расширять их в приложении. + +## Быстрый старт + +Основные публичные точки входа: + +- `ViolationCodeEntry` - компактная контрактная запись для известного кода; +- `ViolationCodeRegistry` - интерфейс-реестр известных кодов; +- `ViolationCode` - union, извлекаемый из `keyof ViolationCodeRegistry`; +- `ViolationArgs`, `ViolationKindOf` и `ViolationNameOf` - code-driven utility types. + +Пакет уже содержит built-in ключи для собственных violations, например: + +- `'type.string'` +- `'length.min'` +- `'shape.unknown-key'` +- `'runtime.rejection'` + +Поэтому такой код работает сразу: + +```typescript +import type { ViolationCode } from '@modulify/validator' + +const code: ViolationCode = 'type.string' +``` + +## Точные коды из `describe(...)` + +Built-in assertions теперь сохраняют точные literal-коды в интроспекции. + +```typescript +import { + describe, + hasLength, + isString, +} from '@modulify/validator' + +const stringDescriptor = describe(isString) +const lengthDescriptor = describe(hasLength({ min: 3 })) +``` + +С точки зрения TypeScript это значит: + +- `stringDescriptor.code` имеет тип `'type.string'`; +- `stringDescriptor.args` имеет тип `[]`; +- `lengthDescriptor.code` имеет тип `'length.unsupported-type'`; +- `lengthDescriptor.constraints[number].code` имеет конкретный union length-кодов вместо обычного `string`. + +Это удобно для адаптеров и tooling-кода, который читает descriptors и хочет ветвиться по коду без ручных cast. + +## Зачем нужен глобальный реестр + +Точные literals на отдельных значениях полезны для локальной интроспекции. + +Глобальный реестр решает другую задачу: позволяет получить один переиспользуемый union для всего приложения. + +```typescript +import type { ViolationCode } from '@modulify/validator' + +type AppViolationCode = ViolationCode +``` + +Такой union удобно использовать в: + +- словарях сообщений; +- контрактах аналитики; +- API envelopes с ошибками; +- UI-мапперах состояния ошибок; +- общих helper utilities. + +## Расширение `ViolationCodeRegistry` + +Реестр рассчитан на module augmentation и теперь хранит небольшие контрактные записи по коду. + +```typescript +import type { ViolationCodeEntry } from '@modulify/validator' +import '@modulify/validator' + +declare module '@modulify/validator' { + interface ViolationCodeRegistry { + 'user.email.taken': ViolationCodeEntry<'validator', 'user', readonly []>; + 'profile.password.mismatch': ViolationCodeEntry<'validator', 'shape', readonly []>; + } +} +``` + +После этого: + +```typescript +import type { ViolationCode } from '@modulify/validator' + +const codeA: ViolationCode = 'user.email.taken' +const codeB: ViolationCode = 'profile.password.mismatch' +``` + +Так можно один раз объявить project-specific коды и потом использовать извлечённый union во всех остальных слоях. + +Если в проекте ещё остались старые augmentation-записи с `never`, они по-прежнему будут попадать в `ViolationCode`, но для `kind` / `name` / `args` останется generic fallback, пока вы не переведёте их на `ViolationCodeEntry`. + +## Производные типы от кода + +После регистрации кода с контрактной записью он становится ключом к связанным типам. + +```typescript +import type { + ViolationArgs, + ViolationKindOf, + ViolationNameOf, + ViolationSubject, +} from '@modulify/validator' + +type PasswordArgs = ViolationArgs<'profile.password.mismatch'> +type PasswordKind = ViolationKindOf<'profile.password.mismatch'> +type PasswordName = ViolationNameOf<'profile.password.mismatch'> +type PasswordSubject = ViolationSubject<'profile.password.mismatch'> +``` + +То есть: + +- `PasswordArgs` становится `readonly []`; +- `PasswordKind` становится `'validator'`; +- `PasswordName` становится `'shape'`; +- `PasswordSubject` автоматически получает согласованные `kind`, `name`, `code` и `args`. + +## Использование расширенных кодов в custom assertions + +Custom assertions могут хранить свои собственные явные literal-коды. + +```typescript +import { assert } from '@modulify/validator/assertions' + +const isAvailableEmail = assert( + (value: unknown): value is string => typeof value === 'string' && value.includes('@'), + { + name: 'isAvailableEmail', + bail: true, + code: 'user.email.taken', + } +) +``` + +Тогда `describe(isAvailableEmail).code` будет иметь тип `'user.email.taken'`. + +Эта часть не зависит от глобального union. Literal сохраняется прямо из определения assertion. + +## Использование расширенных кодов в shape refinements + +Та же идея работает и для object-level refinement issues. + +```typescript +import type { ObjectShapeRefinementIssue } from '@modulify/validator' +import { + isEmail, + isString, + shape, +} from '@modulify/validator' + +const signUpForm = shape({ + email: [isString, isEmail], + password: isString, + confirmation: shape({ + password: isString, + }), +}).refine(value => { + if (value.password === value.confirmation.password) { + return [] + } + + return [{ + path: ['confirmation', 'password'], + code: 'profile.password.mismatch', + args: [], + }] satisfies ObjectShapeRefinementIssue<'profile.password.mismatch'> +}) +``` + +Так код refinement остаётся согласованным с тем же реестром, из которого вы строите общий union. + +## Практический паттерн для app-level мапперов + +Часто поверх codes хочется сделать небольшой слой, который отвечает уже за рендеринг или транспорт. + +```typescript +import type { + Violation, + ViolationCode, +} from '@modulify/validator' + +const labels: Partial> = { + 'type.string': 'Expected a string', + 'length.min': 'Value is too short', + 'user.email.taken': 'Email is already taken', +} + +function toLabel(violation: Violation) { + return labels[violation.violates.code as ViolationCode] ?? violation.violates.code +} +``` + +Необязательно превращать все возможные коды в один огромный исчерпывающий словарь. На практике `Partial>` часто самый удобный вариант. + +## Built-In union и сохранение явных literals + +Эти два механизма дополняют друг друга: + +- built-in и augmented коды попадают в переиспользуемый union `ViolationCode`; +- явные custom literals сохраняются прямо в местах создания значения, например в `assert(...)` или typed refinement issues. + +Это важное различие. + +Если вы определили custom literal, но ещё не аугментировали `ViolationCodeRegistry`: + +- локальные descriptor и violation значения всё равно могут нести точный literal; +- глобальный union `ViolationCode` пока не будет его содержать. + +Если вы аугментировали `ViolationCodeRegistry` через `never`, а не через `ViolationCodeEntry`: + +- код попадёт в глобальный union `ViolationCode`; +- для него всё ещё будет использоваться generic fallback по `kind`, `name` и `args`. + +Обычно удобно делать так: + +1. объявить custom code там, где он создаётся; +2. добавить его в `ViolationCodeRegistry`; +3. использовать `ViolationCode` в адаптерах и app-level helper types. + +## Связанные разделы + +- [Метаданные и интроспекция](./02-metadata-and-introspection.md) +- [Нарушения](./03-violations.md) +- [Публичный API](./05-public-api.md) diff --git a/docs/ru/README.md b/docs/ru/README.md index f5488e8..c8f770b 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -261,6 +261,7 @@ const node = describe(registration) Подробные руководства: - [Руководство по метаданным и интроспекции](./02-metadata-and-introspection.md) +- [Руководство по типам кодов нарушений](./08-violation-code-types.md) ## Ментальная модель @@ -308,6 +309,7 @@ const emailErrors = errors.at(['profile', 'email']) Подробные руководства: - [Руководство по нарушениям](./03-violations.md) +- [Руководство по типам кодов нарушений](./08-violation-code-types.md) ## Экспорт JSON Schema diff --git a/src/assert.ts b/src/assert.ts index 3cc48aa..064785d 100644 --- a/src/assert.ts +++ b/src/assert.ts @@ -1,5 +1,6 @@ import type { Assertion, + AssertionConstraintSubject, AssertionConstraint, Predicate, } from '~types' @@ -10,43 +11,71 @@ type AssertMeta = { name: string; bail: boolean; code?: string; - args?: unknown[]; + args?: readonly unknown[]; } -export const assert = ( +type ResolveAssertionCode = Code extends string ? Code : Name + +type ResolveAssertionArgs = Args extends readonly unknown[] ? Args : [] + +export const assert = < + T, + const Name extends string, + const Bail extends boolean, + const Code extends string | undefined = undefined, + const Args extends readonly unknown[] | undefined = undefined, + const C extends readonly AssertionConstraint[] = [], +>( predicate: Predicate, - meta: AssertMeta, - constraints: C = [] as C -): Assertion => { - const assertion = ((value: unknown): ReturnType> => { + meta: AssertMeta & { + name: Name; + bail: Bail; + code?: Code; + args?: Args; + }, + constraints: C = [] as unknown as C +): Assertion, ResolveAssertionArgs, Name> => { + const assertionCode = (meta.code ?? meta.name) as ResolveAssertionCode + const assertionArgs = (meta.args ?? []) as ResolveAssertionArgs + const violationSubject = { + kind: 'assertion', + name: meta.name, + code: assertionCode, + args: assertionArgs, + } as { + kind: 'assertion'; + name: Name; + code: ResolveAssertionCode; + args: ResolveAssertionArgs; + } + const assertion = ((value: unknown): ReturnType< + Assertion, ResolveAssertionArgs, Name> + > => { if (!predicate(value)) { return { value, - violates: { - kind: 'assertion', - name: meta.name, - code: meta.code ?? meta.name, - args: meta.args ?? [], - }, + violates: violationSubject, } } for (const [extract, check, code, ...args] of constraints) { if (!check(extract(value), ...args)) { + const constraintSubject = { + kind: 'assertion', + name: meta.name, + code, + args, + } as unknown as AssertionConstraintSubject + return { value, - violates: { - kind: 'assertion', - name: meta.name, - code, - args, - }, + violates: constraintSubject, } } } return null - }) as Assertion + }) as Assertion, ResolveAssertionArgs, Name> Object.defineProperties(assertion, { name: { @@ -73,8 +102,8 @@ export const assert = ({ code, args, diff --git a/src/assertions.ts b/src/assertions.ts index f86f816..a8591c3 100644 --- a/src/assertions.ts +++ b/src/assertions.ts @@ -39,6 +39,24 @@ export { assert } // eslint-disable-next-line @typescript-eslint/no-empty-object-type type Defined = {} | null +type LengthAssertionConstraint = + | AssertionConstraint + | AssertionConstraint + | AssertionConstraint + | AssertionConstraint + +type SizeAssertionConstraint = + | AssertionConstraint | Set, number, [exact: number], 'size.exact'> + | AssertionConstraint | Set, number, [max: number], 'size.max'> + | AssertionConstraint | Set, number, [min: number], 'size.min'> + | AssertionConstraint | Set, number, [range: [number, number]], 'size.range'> + +type ValueAssertionConstraint = + | AssertionConstraint + | AssertionConstraint + | AssertionConstraint + | AssertionConstraint + export const isBoolean = assert(_isBoolean, { name: 'isBoolean', bail: true, code: 'type.boolean' }) export const isBigInt = assert(_isBigInt, { name: 'isBigInt', bail: true, code: 'type.bigint' }) export const isBlob = assert(_isBlob, { name: 'isBlob', bail: true, code: 'type.blob' }) @@ -72,12 +90,12 @@ export const hasLength = ({ range?: [number, number] | null; bail?: boolean; } = {}) => { - const constraints: AssertionConstraint[] = [] + const constraints: LengthAssertionConstraint[] = [] - if (exact !== null) constraints.push([length, isEqual, 'length.exact', exact]) - if (max !== null) constraints.push([length, isLte, 'length.max', max]) - if (min !== null) constraints.push([length, isGte, 'length.min', min]) - if (range !== null) constraints.push([length, inRange, 'length.range', range]) + if (exact !== null) constraints.push([length, isEqual, 'length.exact', exact] as const) + if (max !== null) constraints.push([length, isLte, 'length.max', max] as const) + if (min !== null) constraints.push([length, isGte, 'length.min', min] as const) + if (range !== null) constraints.push([length, inRange, 'length.range', range] as const) return assert( (value: unknown): value is string | unknown[] => isArray(value) || _isString(value), @@ -103,12 +121,12 @@ export const hasSize = ({ range?: [number, number] | null; bail?: boolean; } = {}) => { - const constraints: AssertionConstraint | Set>[] = [] + const constraints: SizeAssertionConstraint[] = [] - if (exact !== null) constraints.push([size, isEqual, 'size.exact', exact]) - if (max !== null) constraints.push([size, isLte, 'size.max', max]) - if (min !== null) constraints.push([size, isGte, 'size.min', min]) - if (range !== null) constraints.push([size, inRange, 'size.range', range]) + if (exact !== null) constraints.push([size, isEqual, 'size.exact', exact] as const) + if (max !== null) constraints.push([size, isLte, 'size.max', max] as const) + if (min !== null) constraints.push([size, isGte, 'size.min', min] as const) + if (range !== null) constraints.push([size, inRange, 'size.range', range] as const) return assert( (value: unknown): value is Map | Set => _isMap(value) || _isSet(value), @@ -140,7 +158,7 @@ export const hasPattern = ( matchesPattern, 'string.pattern', pattern, - ]] + ]] as const ) export const startsWith = ( @@ -162,7 +180,7 @@ export const startsWith = ( _startsWith, 'string.starts-with', prefix, - ]] + ]] as const ) export const endsWith = ( @@ -184,7 +202,7 @@ export const endsWith = ( _endsWith, 'string.ends-with', suffix, - ]] + ]] as const ) export const hasValue = ({ @@ -200,12 +218,12 @@ export const hasValue = ({ range?: [number, number] | null; bail?: boolean; } = {}) => { - const constraints: AssertionConstraint[] = [] + const constraints: ValueAssertionConstraint[] = [] - if (exact !== null) constraints.push([(value: number) => value, isEqual, 'number.exact', exact]) - if (max !== null) constraints.push([(value: number) => value, isLte, 'number.max', max]) - if (min !== null) constraints.push([(value: number) => value, isGte, 'number.min', min]) - if (range !== null) constraints.push([(value: number) => value, inRange, 'number.range', range]) + if (exact !== null) constraints.push([(value: number) => value, isEqual, 'number.exact', exact] as const) + if (max !== null) constraints.push([(value: number) => value, isLte, 'number.max', max] as const) + if (min !== null) constraints.push([(value: number) => value, isGte, 'number.min', min] as const) + if (range !== null) constraints.push([(value: number) => value, inRange, 'number.range', range] as const) return assert( _isNumber, @@ -237,7 +255,7 @@ export const multipleOf = ( isMultipleOf, 'number.multiple-of', step, - ]] + ]] as const ) export const oneOf = ( diff --git a/src/combinators.ts b/src/combinators.ts index ae285e4..a7444df 100644 --- a/src/combinators.ts +++ b/src/combinators.ts @@ -76,14 +76,19 @@ type InferDiscriminatedUnion = { type StrictObjectShape< D extends ShapeDescriptor, R extends readonly ObjectShapeRuleDescriptor[] = readonly ObjectShapeRuleDescriptor[], -> = ObjectShape + RI extends ObjectShapeRefinementIssue = never, +> = ObjectShape type PassthroughObjectShape< D extends ShapeDescriptor, R extends readonly ObjectShapeRuleDescriptor[] = readonly ObjectShapeRuleDescriptor[], -> = ObjectShape + RI extends ObjectShapeRefinementIssue = never, +> = ObjectShape -type ShapeRefinement = ObjectShapeRefinement +type ShapeRefinement< + T, + I extends ObjectShapeRefinementIssue = ObjectShapeRefinementIssue, +> = ObjectShapeRefinement const describeConstraintMap = >>( constraints: T @@ -207,10 +212,13 @@ const toPath = (selector: ObjectShapeFieldSelector): PropertyKey[] => Array.isAr ? [...selector] : [selector] -const collectShapeRefinementViolations = ( +const collectShapeRefinementViolations = < + D extends ShapeDescriptor, + RI extends ObjectShapeRefinementIssue, +>( value: InferShape, path: PropertyKey[], - refinements: readonly ShapeRefinement>[] + refinements: readonly ShapeRefinement, RI>[] ) => refinements.flatMap(refinement => { const result = refinement(value) @@ -219,7 +227,7 @@ const collectShapeRefinementViolations = ( } return arrayify(result) - .filter((issue): issue is ObjectShapeRefinementIssue => issue !== null && issue !== undefined) + .filter((issue): issue is RI => issue !== null && issue !== undefined) .map(issue => { const issuePath = issue.path ? [...issue.path] : [] @@ -240,12 +248,13 @@ const createObjectShape = < const D extends ShapeDescriptor, const M extends UnknownKeysMode, const R extends readonly ObjectShapeRuleDescriptor[], + RI extends ObjectShapeRefinementIssue = never, >( descriptor: D, unknownKeys: M, - refinements: readonly ShapeRefinement>[], + refinements: readonly ShapeRefinement, RI>[], rules: R -): ObjectShape => { +): ObjectShape => { const keys = keysOf(descriptor) return attachConstraintDescriptor({ @@ -295,14 +304,17 @@ const createObjectShape = < ? validations : [collectShapeRefinementViolations(value as InferShape, path, refinements) as Validation] }, - refine>( - refinement: ShapeRefinement>, + refine< + const I extends ObjectShapeRefinementIssue = ObjectShapeRefinementIssue, + const RD extends GenericObjectShapeRuleDescriptor = GenericObjectShapeRuleDescriptor<'refine'> + >( + refinement: ShapeRefinement, I>, ruleDescriptor: RD = { kind: 'refine' } as RD ) { - return createObjectShape( + return createObjectShape( descriptor, unknownKeys, - [...refinements, refinement], + [...refinements, refinement] as readonly ShapeRefinement, RI | I>[], [...rules, normalizeRuleDescriptor(ruleDescriptor)] as [...R, RD] ) }, @@ -315,7 +327,12 @@ const createObjectShape = < selectors: [left, right], } as FieldsMatchObjectShapeRuleDescriptor - return createObjectShape( + return createObjectShape< + D, + M, + [...R, FieldsMatchObjectShapeRuleDescriptor], + RI | ObjectShapeRefinementIssue<'shape.fields.mismatch'> + >( descriptor, unknownKeys, [...refinements, value => { @@ -326,14 +343,17 @@ const createObjectShape = < code: 'shape.fields.mismatch', args: [selectedKeys], }] - }], + }] as readonly ShapeRefinement< + InferShape, + RI | ObjectShapeRefinementIssue<'shape.fields.mismatch'> + >[], [...rules, ruleDescriptor] as [...R, FieldsMatchObjectShapeRuleDescriptor] ) }, - strict(): StrictObjectShape { + strict(): StrictObjectShape { return createObjectShape(descriptor, 'strict', refinements, rules) }, - passthrough(): PassthroughObjectShape { + passthrough(): PassthroughObjectShape { return createObjectShape(descriptor, 'passthrough', refinements, rules) }, pick(selectedKeys: K) { @@ -349,7 +369,7 @@ const createObjectShape = < return createObjectShape(extendDescriptor(descriptor, extension), unknownKeys, [], []) }, merge( - shape: ObjectShape + shape: ObjectShape ) { return createObjectShape(extendDescriptor(descriptor, shape.descriptor), unknownKeys, [], []) }, @@ -543,7 +563,7 @@ export const record = >(constraints: C): R values: describeConstraints(constraints), })) -export const shape = (descriptor: D): ObjectShape => { +export const shape = (descriptor: D): ObjectShape => { return createObjectShape(descriptor, 'passthrough', [], []) } diff --git a/src/index.ts b/src/index.ts index a25d25b..28600ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import type { Constraint, InferConstraints, + InferMaybeManyViolations, MaybeMany, Recursive, ValidationTuple, @@ -27,6 +28,8 @@ export type { ConstraintDescriptorBase, ConstraintMetadata, CustomConstraintDescriptor, + DescribeAssertionConstraint, + DescribeAssertionConstraintTuple, DescribeConstraint, DescribeConstraintTuple, DescribeMaybeMany, @@ -38,10 +41,14 @@ export type { GenericObjectShapeRuleDescriptor, InferConstraint, InferConstraints, + InferConstraintViolations, + InferMaybeManyViolations, DiscriminatedUnionConstraintDescriptor, EachConstraintDescriptor, NullableValidator, NullishValidator, + KnownViolationCode, + KnownViolationSubject, ObjectShapeFieldSelector, ObjectShapeRefinement, ObjectShapeRefinementIssue, @@ -56,7 +63,14 @@ export type { UnionValidator, ValidationFailure, ValidationSuccess, + ViolationArgs, + ViolationCode, + ViolationCodeEntry, + ViolationCodeRegistry, + ViolationEntry, ViolationKind, + ViolationKindOf, + ViolationNameOf, ViolationSubject, ViolationTreeNode, UnionConstraintDescriptor, @@ -135,7 +149,7 @@ const collectViolationsSync = ( return flatten(violations) as Violation[] } -function toResult(value: unknown, violations: Violation[]): ValidationTuple { +function toResult(value: unknown, violations: V[]): ValidationTuple { return violations.length === 0 ? [true, value as T, []] : [false, value, violations] @@ -184,19 +198,25 @@ export const validate = Object.assign( async >( value: unknown, constraints: C - ): Promise>> => { + ): Promise, InferMaybeManyViolations>> => { const violations = await collectViolations(value, constraints) - return toResult>(value, violations) + return toResult, InferMaybeManyViolations>( + value, + violations as InferMaybeManyViolations[] + ) }, { sync>( value: unknown, constraints: C - ): ValidationTuple> { + ): ValidationTuple, InferMaybeManyViolations> { const violations = collectViolationsSync(value, constraints) - return toResult>(value, violations) + return toResult, InferMaybeManyViolations>( + value, + violations as InferMaybeManyViolations[] + ) }, } ) diff --git a/tests/combinators.test.ts b/tests/combinators.test.ts index 0b4a888..be6bd0a 100644 --- a/tests/combinators.test.ts +++ b/tests/combinators.test.ts @@ -28,8 +28,24 @@ import { validate, } from '@/index' -const assertionSubject = (name: string, code: string, args: unknown[] = []): ViolationSubject => ({ kind: 'assertion', name, code, args }) -const validatorSubject = (name: string, code: string, args: unknown[] = []): ViolationSubject => ({ kind: 'validator', name, code, args }) +const assertionSubject = ( + name: string, + code: C, + args: A = [] as unknown as A +): ViolationSubject => ({ kind: 'assertion', name, code, args }) as unknown as ViolationSubject< + A, + 'assertion', + C +> +const validatorSubject = ( + name: string, + code: C, + args: A = [] as unknown as A +): ViolationSubject => ({ kind: 'validator', name, code, args }) as unknown as ViolationSubject< + A, + 'validator', + C +> const createAsyncAssertion = ( name: string, diff --git a/tests/metadata.test-d.ts b/tests/metadata.test-d.ts index 0825b8f..c4bac99 100644 --- a/tests/metadata.test-d.ts +++ b/tests/metadata.test-d.ts @@ -4,6 +4,7 @@ import type { FieldsMatchObjectShapeRuleDescriptor, GenericObjectShapeRuleDescriptor, ValidationTuple, + ViolationCode, } from '@/index' import { @@ -52,12 +53,17 @@ describe('metadata and introspection types', () => { if (descriptor.child.kind === 'assertion') { assertType(descriptor.child.name) assertType(descriptor.child.bail) - assertType(descriptor.child.code) - assertType(descriptor.child.args) + assertType<'type.string'>(descriptor.child.code) + assertType<[]>(descriptor.child.args) } } }) + test('violation code registry exposes built-in code unions', () => { + assertType('type.string') + assertType('shape.unknown-key') + }) + test('shape descriptors expose fields and rule descriptors', () => { const schema = shape({ password: isString, diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 5dea8b5..5738f1f 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -27,9 +27,33 @@ import { validate } from '@/index' const expectProfile = (value: T) => value const valid = (value: T) => [true, value, []] const invalid = (value: T, violations: unknown[]) => [false, value, violations] -const assertionSubject = (name: string, code: string, args: unknown[] = []): ViolationSubject => ({ kind: 'assertion', name, code, args }) -const validatorSubject = (name: string, code: string, args: unknown[] = []): ViolationSubject => ({ kind: 'validator', name, code, args }) -const runtimeSubject = (name: string, code: string, args: unknown[] = []): ViolationSubject => ({ kind: 'runtime', name, code, args }) +const assertionSubject = ( + name: string, + code: C, + args: A = [] as unknown as A +): ViolationSubject => ({ kind: 'assertion', name, code, args }) as unknown as ViolationSubject< + A, + 'assertion', + C +> +const validatorSubject = ( + name: string, + code: C, + args: A = [] as unknown as A +): ViolationSubject => ({ kind: 'validator', name, code, args }) as unknown as ViolationSubject< + A, + 'validator', + C +> +const runtimeSubject = ( + name: string, + code: C, + args: A = [] as unknown as A +): ViolationSubject => ({ kind: 'runtime', name, code, args }) as unknown as ViolationSubject< + A, + 'runtime', + C +> const createAsyncAssertion = ( name: string, diff --git a/tests/validate.tuple.test-d.ts b/tests/validate.tuple.test-d.ts index 95f14ee..c9e947b 100644 --- a/tests/validate.tuple.test-d.ts +++ b/tests/validate.tuple.test-d.ts @@ -10,7 +10,9 @@ import { } from 'vitest' import { + collection, each, + hasLength, shape, isDefined, isString, @@ -102,4 +104,27 @@ describe('validate tuple types', () => { assertType(violations) } }) + + test('narrows violation payloads by code inside ViolationCollection.map', () => { + const [ok, , violations] = validate.sync('ab', [isString, hasLength({ min: 3 })]) + + if (!ok) { + const messages = collection(violations).map(violation => { + switch (violation.violates.code) { + case 'type.string': + assertType<'isString'>(violation.violates.name) + assertType<[]>(violation.violates.args) + return violation.violates.name + case 'length.min': + assertType<'hasLength'>(violation.violates.name) + assertType(violation.violates.args) + return String(violation.violates.args[0]) + default: + return violation.violates.code + } + }) + + assertType(messages) + } + }) }) diff --git a/tests/violation-codes.test-d.ts b/tests/violation-codes.test-d.ts new file mode 100644 index 0000000..6a67a4d --- /dev/null +++ b/tests/violation-codes.test-d.ts @@ -0,0 +1,92 @@ +declare module '@/index' { + interface ViolationCodeRegistry { + 'app.user.conflict': import('@/index').ViolationCodeEntry<'validator', 'user', readonly [id: string]>; + 'shape.password.mismatch': import('@/index').ViolationCodeEntry<'validator', 'shape', readonly []>; + 'legacy.only-code': never; + } +} + +import type { + KnownViolationSubject, + ObjectShapeRefinementIssue, + ViolationArgs, + ViolationCode, + ViolationEntry, + ViolationKindOf, + ViolationNameOf, + ViolationSubject, +} from '@/index' + +import { + assertType, + describe, + test, +} from 'vitest' + +import { assert } from '@/assertions' +import { describe as describeConstraint } from '@/index' + +describe('violation code registry augmentation', () => { + test('accepts externally augmented codes and derives their contracts', () => { + assertType('app.user.conflict') + assertType('shape.password.mismatch') + assertType('legacy.only-code') + + const subject = { + kind: 'validator' as const, + name: 'user', + code: 'app.user.conflict' as const, + args: ['user-1'] as const, + } satisfies ViolationSubject<'app.user.conflict'> + + const strictSubject = { + kind: 'validator' as const, + name: 'shape', + code: 'shape.password.mismatch' as const, + args: [] as const, + } satisfies KnownViolationSubject<'shape.password.mismatch'> + + const issue = { + code: 'shape.password.mismatch' as const, + args: [] as const, + } satisfies ObjectShapeRefinementIssue<'shape.password.mismatch'> + + const legacySubject = { + kind: 'runtime' as const, + name: 'legacy', + code: 'legacy.only-code' as const, + args: ['anything'] as const, + } satisfies ViolationSubject<'legacy.only-code'> + + assertType<'app.user.conflict'>(subject.code) + assertType(subject.args) + assertType(strictSubject.args) + assertType<'shape.password.mismatch'>(issue.code) + assertType({} as ViolationArgs<'app.user.conflict'>) + assertType<'validator'>({} as ViolationKindOf<'app.user.conflict'>) + assertType<'user'>({} as ViolationNameOf<'app.user.conflict'>) + assertType<{ + kind: 'validator'; + name: 'user'; + args: readonly [id: string]; + }>({} as ViolationEntry<'app.user.conflict'>) + assertType(legacySubject.name) + }) + + test('preserves explicit custom assertion codes in descriptors', () => { + const customAssertion = assert( + (value: unknown): value is string => typeof value === 'string', + { + name: 'isUserId', + bail: true, + code: 'app.user.conflict', + } + ) + + const descriptor = describeConstraint(customAssertion) + + if (descriptor.kind === 'assertion') { + assertType<'app.user.conflict'>(descriptor.code) + } + }) +}) diff --git a/tests/violations.test-d.ts b/tests/violations.test-d.ts index a980a9e..0d6a76f 100644 --- a/tests/violations.test-d.ts +++ b/tests/violations.test-d.ts @@ -39,7 +39,7 @@ describe('violation collection types', () => { assertType>(errors) assertType>(emailErrors) - assertType(emailErrors.map(violation => violation.violates.code)) + assertType<'type.string'[]>(emailErrors.map(violation => violation.violates.code)) assertType>(tree) assertType | undefined>(emailNode) assertType>(tree.self) diff --git a/tests/violations.test.ts b/tests/violations.test.ts index d46f8ef..1e98929 100644 --- a/tests/violations.test.ts +++ b/tests/violations.test.ts @@ -16,8 +16,24 @@ import { import { shape } from '@/combinators' import { isString } from '@/assertions' -const assertionSubject = (name: string, code: string, args: unknown[] = []): ViolationSubject => ({ kind: 'assertion', name, code, args }) -const validatorSubject = (name: string, code: string, args: unknown[] = []): ViolationSubject => ({ kind: 'validator', name, code, args }) +const assertionSubject = ( + name: string, + code: C, + args: A = [] as unknown as A +): ViolationSubject => ({ kind: 'assertion', name, code, args }) as unknown as ViolationSubject< + A, + 'assertion', + C +> +const validatorSubject = ( + name: string, + code: C, + args: A = [] as unknown as A +): ViolationSubject => ({ kind: 'validator', name, code, args }) as unknown as ViolationSubject< + A, + 'validator', + C +> describe('collection', () => { test('supports iteration, forEach and map while preserving order', () => { diff --git a/types/index.d.ts b/types/index.d.ts index dc29842..70c4db1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -22,7 +22,7 @@ export type MaybePromise = V | Promise export type Predicate = (value: unknown) => value is T /** Checker used by assertion constraints after a value is extracted. */ -export type Checker = (value: T, ...args: A) => boolean +export type Checker = (value: T, ...args: A) => boolean /** Extracts a derived value from the original input before a checker runs. */ export type Extractor = (value: T) => V @@ -30,17 +30,160 @@ export type Extractor = (value: T) => V /** Origin layer that produced a violation. */ export type ViolationKind = 'assertion' | 'validator' | 'runtime' -/** Machine-readable description of a validation failure. */ -export type ViolationSubject< - T extends unknown[] = unknown[], - K extends ViolationKind = ViolationKind +/** Contract stored in `ViolationCodeRegistry` for a known machine-readable code. */ +export type ViolationCodeEntry< + K extends ViolationKind = ViolationKind, + N extends string = string, + A extends readonly unknown[] = readonly unknown[], > = { kind: K; - name: string; - code: string; - args: T; + name: N; + args: A; } +type ResolveViolationEntry = + [E] extends [never] + ? ViolationCodeEntry + : E extends ViolationCodeEntry + ? E + : ViolationCodeEntry + +type ResolveViolationSubjectCode = COrT extends string ? COrT : string +type ResolveViolationSubjectArgs = [COrT] extends [readonly unknown[]] + ? COrT + : ViolationArgs + +/** + * Extensible registry of machine-readable violation codes. + * + * Consumers can augment this interface in their app code: + * `declare module '@modulify/validator' { interface ViolationCodeRegistry { 'app.user.conflict': ViolationCodeEntry<'validator', 'user', readonly []> } }` + * + * Legacy `never` markers remain supported as a fallback for gradual migration: + * `declare module '@modulify/validator' { interface ViolationCodeRegistry { 'legacy.code': never } }` + */ +export interface ViolationCodeRegistry { + 'length.exact': ViolationCodeEntry<'assertion', 'hasLength', readonly [exact: number]>; + 'length.max': ViolationCodeEntry<'assertion', 'hasLength', readonly [max: number]>; + 'length.min': ViolationCodeEntry<'assertion', 'hasLength', readonly [min: number]>; + 'length.range': ViolationCodeEntry<'assertion', 'hasLength', readonly [range: readonly [number, number]]>; + 'length.unsupported-type': ViolationCodeEntry<'assertion', 'hasLength', readonly []>; + 'number.exact': ViolationCodeEntry<'assertion', 'hasValue', readonly [exact: number]>; + 'number.max': ViolationCodeEntry<'assertion', 'hasValue', readonly [max: number]>; + 'number.min': ViolationCodeEntry<'assertion', 'hasValue', readonly [min: number]>; + 'number.multiple-of': ViolationCodeEntry<'assertion', 'multipleOf', readonly [step: number]>; + 'number.nan': ViolationCodeEntry<'assertion', 'isNaN', readonly []>; + 'number.range': ViolationCodeEntry<'assertion', 'hasValue', readonly [range: readonly [number, number]]>; + 'number.unsupported-type': ViolationCodeEntry<'assertion', 'hasValue' | 'multipleOf', readonly []>; + 'runtime.rejection': ViolationCodeEntry<'runtime', 'validate', readonly [reason: unknown]>; + 'shape.fields.mismatch': ViolationCodeEntry< + 'validator', + 'shape', + readonly [selectors: readonly [ObjectShapeFieldSelector, ObjectShapeFieldSelector]] + >; + 'shape.unknown-key': ViolationCodeEntry<'validator', 'shape', readonly []>; + 'size.exact': ViolationCodeEntry<'assertion', 'hasSize', readonly [exact: number]>; + 'size.max': ViolationCodeEntry<'assertion', 'hasSize', readonly [max: number]>; + 'size.min': ViolationCodeEntry<'assertion', 'hasSize', readonly [min: number]>; + 'size.range': ViolationCodeEntry<'assertion', 'hasSize', readonly [range: readonly [number, number]]>; + 'size.unsupported-type': ViolationCodeEntry<'assertion', 'hasSize', readonly []>; + 'string.email': ViolationCodeEntry<'assertion', 'isEmail', readonly []>; + 'string.ends-with': ViolationCodeEntry<'assertion', 'endsWith', readonly [suffix: string]>; + 'string.pattern': ViolationCodeEntry<'assertion', 'hasPattern', readonly [pattern: RegExp]>; + 'string.starts-with': ViolationCodeEntry<'assertion', 'startsWith', readonly [prefix: string]>; + 'string.unsupported-type': ViolationCodeEntry< + 'assertion', + 'hasPattern' | 'startsWith' | 'endsWith', + readonly [] + >; + 'tuple.length': ViolationCodeEntry<'validator', 'tuple', readonly [length: number]>; + 'type.array': ViolationCodeEntry<'validator', 'each' | 'tuple', readonly []>; + 'type.bigint': ViolationCodeEntry<'assertion', 'isBigInt', readonly []>; + 'type.blob': ViolationCodeEntry<'assertion', 'isBlob', readonly []>; + 'type.boolean': ViolationCodeEntry<'assertion', 'isBoolean', readonly []>; + 'type.date': ViolationCodeEntry<'assertion', 'isDate', readonly []>; + 'type.file': ViolationCodeEntry<'assertion', 'isFile', readonly []>; + 'type.function': ViolationCodeEntry<'assertion', 'isFunction', readonly []>; + 'type.map': ViolationCodeEntry<'assertion', 'isMap', readonly []>; + 'type.null': ViolationCodeEntry<'assertion', 'isNull', readonly []>; + 'type.number': ViolationCodeEntry<'assertion', 'isNumber', readonly []>; + 'type.record': ViolationCodeEntry<'validator', 'shape' | 'discriminatedUnion' | 'record', readonly []>; + 'type.set': ViolationCodeEntry<'assertion', 'isSet', readonly []>; + 'type.string': ViolationCodeEntry<'assertion', 'isString', readonly []>; + 'type.symbol': ViolationCodeEntry<'assertion', 'isSymbol', readonly []>; + 'union.invalid-discriminator': ViolationCodeEntry< + 'validator', + 'discriminatedUnion', + readonly [variants: readonly PropertyKey[]] + >; + 'union.no-match': ViolationCodeEntry<'validator', 'union', readonly [branches: number]>; + 'value.defined': ViolationCodeEntry<'assertion', 'isDefined', readonly []>; + 'value.exact': ViolationCodeEntry<'assertion', 'exact', readonly [expected: unknown]>; + 'value.one-of': ViolationCodeEntry<'assertion', 'oneOf', readonly [values: readonly unknown[]]>; +} + +/** Union of all registered machine-readable violation codes. */ +export type ViolationCode = Extract + +/** Registered codes that provide a full contract entry instead of a legacy `never` marker. */ +export type KnownViolationCode = Extract<{ + [C in ViolationCode]: + [ViolationCodeRegistry[C]] extends [never] + ? never + : ViolationCodeRegistry[C] extends ViolationCodeEntry + ? C + : never +}[ViolationCode], string> + +/** Contract entry derived from `ViolationCodeRegistry`, with a generic fallback for unknown or legacy codes. */ +export type ViolationEntry = C extends ViolationCode + ? ResolveViolationEntry + : ViolationCodeEntry + +/** Tuple of machine-readable arguments associated with a violation code. */ +export type ViolationArgs = ViolationEntry['args'] + +/** Origin layer associated with a violation code. */ +export type ViolationKindOf = ViolationEntry['kind'] + +/** Constraint name associated with a violation code. */ +export type ViolationNameOf = ViolationEntry['name'] + +/** Strict code-driven violation subject for a fully registered violation code. */ +export type KnownViolationSubject = { + kind: ViolationKindOf; + name: ViolationNameOf; + code: C; + args: ViolationArgs; +} + +/** Machine-readable description of a validation failure. */ +export type ViolationSubject< + COrT extends string | readonly unknown[] = string, + K extends ViolationKind = COrT extends string ? ViolationKindOf : ViolationKind, + C extends string = ResolveViolationSubjectCode, + T extends readonly unknown[] = ResolveViolationSubjectArgs, +> = [COrT] extends [readonly unknown[]] + ? { + kind: K; + name: string; + code: C; + args: T; + } + : C extends KnownViolationCode + ? { + kind: K & ViolationKindOf; + name: ViolationNameOf; + code: C; + args: T & ViolationArgs; + } + : { + kind: K; + name: string; + code: C; + args: T; + } + /** * Structured validation error returned by assertions and composed validators. * @@ -82,9 +225,12 @@ export declare const collection: (violations: readonly V[]) export type ConstraintMetadata = Readonly> /** Public descriptor entry for additional assertion-level checks. */ -export interface AssertionDescriptorConstraint { - readonly code: string; - readonly args: readonly unknown[] +export interface AssertionDescriptorConstraint< + C extends string = string, + A extends readonly unknown[] = readonly unknown[], +> { + readonly code: C; + readonly args: A } /** Shared descriptor shape returned by `describe(...)`. */ @@ -94,12 +240,16 @@ export interface ConstraintDescriptorBase { } /** Descriptor for leaf assertions created with `assert(...)` or compatible custom assertions. */ -export interface AssertionDescriptor extends ConstraintDescriptorBase<'assertion'> { +export interface AssertionDescriptor< + C extends string = string, + A extends readonly unknown[] = readonly unknown[], + T extends readonly AssertionDescriptorConstraint[] = readonly AssertionDescriptorConstraint[], +> extends ConstraintDescriptorBase<'assertion'> { readonly name: string; readonly bail: boolean; - readonly code?: string; - readonly args?: readonly unknown[]; - readonly constraints: readonly AssertionDescriptorConstraint[] + readonly code: C; + readonly args: A; + readonly constraints: T } /** Generic fallback descriptor for custom validators without structural instrumentation. */ @@ -220,15 +370,39 @@ export type ConstraintDescriptor = BuiltInConstraintDescriptor | CustomConstrain export type AssertionConstraint< T = unknown, V = unknown, - A extends unknown[] = unknown[], + A extends readonly unknown[] = readonly unknown[], C extends string = string -> = [ +> = readonly [ Extractor, Checker, C, ...A ] +/** Maps an assertion checker tuple into its public descriptor entry. */ +export type DescribeAssertionConstraint = + C extends AssertionConstraint + ? AssertionDescriptorConstraint + : never + +/** Maps an assertion checker tuple into the violation subject it can produce. */ +export type AssertionConstraintSubject = + C extends AssertionConstraint + ? { + kind: 'assertion'; + name: N; + code: Code; + args: A; + } + : never + +/** Maps assertion checker tuples into their public descriptor entries. */ +export type DescribeAssertionConstraintTuple = { + readonly [K in keyof T]: T[K] extends AssertionConstraint + ? DescribeAssertionConstraint + : never +} & ReadonlyArray> + /** * Leaf-level validator that checks a single value and either succeeds with `null` * or returns a structured violation. @@ -237,9 +411,19 @@ export type AssertionConstraint< */ export type Assertion< T = unknown, - C extends AssertionConstraint[] = AssertionConstraint[] -> = ((value: unknown) => MaybePromise | null>) & { - readonly name: string; + C extends readonly AssertionConstraint[] = readonly AssertionConstraint[], + Code extends string = string, + A extends readonly unknown[] = readonly unknown[], + Name extends string = string, +> = ((value: unknown) => MaybePromise +>, 'path'> | null>) & { + readonly name: Name; readonly bail: boolean; readonly constraints: C; readonly check: Predicate; @@ -255,7 +439,7 @@ export type Constraint = Assertion | Validator /** Extracts the validated TypeScript type from a single constraint. */ export type InferConstraint = - C extends Assertion + C extends Assertion ? T : C extends Validator ? T @@ -307,24 +491,111 @@ export type UnknownKeysMode = 'passthrough' | 'strict' /** Field selector accepted by shape helpers that can point to the current level or a nested path. */ export type ObjectShapeFieldSelector = PropertyKey | readonly PropertyKey[] +type ResolveObjectShapeRefinementIssueCode = COrA extends string ? COrA : string +type ResolveObjectShapeRefinementIssueArgs = [COrA] extends [readonly unknown[]] + ? COrA + : ViolationArgs + /** Machine-readable issue returned by an object-level shape refinement. */ -export type ObjectShapeRefinementIssue = { +export type ObjectShapeRefinementIssue< + COrA extends string | readonly unknown[] = string, + C extends string = ResolveObjectShapeRefinementIssueCode, + A extends readonly unknown[] = ResolveObjectShapeRefinementIssueArgs, +> = { path?: PropertyKey[]; - code: string; - args?: A; + code: C; + args?: [COrA] extends [readonly unknown[]] + ? A + : C extends KnownViolationCode + ? A & ViolationArgs + : A; value?: unknown; } /** Sync object-level rule that runs after the base shape has validated successfully. */ -export type ObjectShapeRefinement = ( +export type ObjectShapeRefinement< + T, + I extends ObjectShapeRefinementIssue = ObjectShapeRefinementIssue, +> = ( value: T -) => MaybeMany | null | undefined +) => MaybeMany | null | undefined + +type KnownCodeViolation = Violation> + +type ShapeRefinementIssueSubject = + I extends ObjectShapeRefinementIssue + ? C extends KnownViolationCode + ? { + kind: 'validator'; + name: 'shape' & ViolationNameOf; + code: C; + args: A & ViolationArgs; + } + : ViolationSubject + : never + +type ShapeRefinementIssueViolation = + I extends ObjectShapeRefinementIssue + ? Violation> + : never + +type InferObjectDescriptorViolations = { + [K in keyof D]: InferMaybeManyViolations +}[keyof D] + +/** Maps a single constraint into the union of violations it can produce. */ +export type InferConstraintViolations = + C extends Assertion + ? Violation<{ + kind: 'assertion'; + name: Name; + code: Code; + args: Args; + } | AssertionConstraintSubject> + : C extends ObjectShape + ? KnownCodeViolation<'type.record'> + | (M extends 'strict' ? KnownCodeViolation<'shape.unknown-key'> : never) + | InferObjectDescriptorViolations + | ShapeRefinementIssueViolation + : C extends OptionalValidator + ? InferMaybeManyViolations + : C extends NullableValidator + ? InferMaybeManyViolations + : C extends NullishValidator + ? InferMaybeManyViolations + : C extends EachValidator + ? KnownCodeViolation<'type.array'> | InferMaybeManyViolations + : C extends TupleValidator + ? KnownCodeViolation<'type.array'> + | KnownCodeViolation<'tuple.length'> + | InferMaybeManyViolations + : C extends UnionValidator + ? KnownCodeViolation<'union.no-match'> | InferMaybeManyViolations + : C extends DiscriminatedUnionValidator + ? KnownCodeViolation<'type.record'> + | KnownCodeViolation<'union.invalid-discriminator'> + | InferMaybeManyViolations + : C extends RecordValidator + ? KnownCodeViolation<'type.record'> | InferMaybeManyViolations + : C extends Validator + ? Violation + : never + +/** Maps one-or-many constraints into the union of violations they can produce. */ +export type InferMaybeManyViolations> = + C extends readonly [] + ? never + : C extends readonly Constraint[] + ? InferConstraintViolations + : C extends Constraint + ? InferConstraintViolations + : never /** Successful `validate(...)` tuple with typed `validated` value. */ export type ValidationSuccess = [ok: true, validated: T, violations: []] /** Failed `validate(...)` tuple with original value and collected violations. */ -export type ValidationFailure = [ok: false, validated: unknown, violations: Violation[]] +export type ValidationFailure = [ok: false, validated: unknown, violations: V[]] /** * The result tuple returned by `validate(...)` and `validate.sync(...)`. @@ -335,10 +606,10 @@ export type ValidationFailure = [ok: false, validated: unknown, violations: Viol * Example: * `if (ok) validated.name.toUpperCase()` */ -export type ValidationTuple = ValidationSuccess | ValidationFailure +export type ValidationTuple = ValidationSuccess | ValidationFailure /** Alias for `ValidationTuple`. */ -export type ValidationResult = ValidationTuple +export type ValidationResult = ValidationTuple /** Attaches read-only metadata to a constraint without changing validation semantics. */ export declare const meta: (constraint: C, metadata: M) => C @@ -401,35 +672,39 @@ export interface ObjectShape< D extends ObjectDescriptor = ObjectDescriptor, M extends UnknownKeysMode = 'passthrough', R extends readonly ObjectShapeRuleDescriptor[] = readonly ObjectShapeRuleDescriptor[], + RI extends ObjectShapeRefinementIssue = never, > extends Validator> { readonly descriptor: D; readonly unknownKeys: M; - refine( - refinement: ObjectShapeRefinement> - ): ObjectShape]>; - refine>( - refinement: ObjectShapeRefinement>, + refine( + refinement: ObjectShapeRefinement, I> + ): ObjectShape], RI | I>; + refine< + const I extends ObjectShapeRefinementIssue = ObjectShapeRefinementIssue, + const RD extends GenericObjectShapeRuleDescriptor = GenericObjectShapeRuleDescriptor<'refine'> + >( + refinement: ObjectShapeRefinement, I>, descriptor: RD - ): ObjectShape; + ): ObjectShape; fieldsMatch( keys: K - ): ObjectShape]>; - strict(): ObjectShape; - passthrough(): ObjectShape; - pick(keys: K): ObjectShape, M, []>; - omit(keys: K): ObjectShape, M, []>; - partial(): ObjectShape, M, []>; - extend(descriptor: E): ObjectShape, M, []>; + ): ObjectShape], RI | ObjectShapeRefinementIssue<'shape.fields.mismatch'>>; + strict(): ObjectShape; + passthrough(): ObjectShape; + pick(keys: K): ObjectShape, M, [], never>; + omit(keys: K): ObjectShape, M, [], never>; + partial(): ObjectShape, M, [], never>; + extend(descriptor: E): ObjectShape, M, [], never>; merge( - shape: ObjectShape - ): ObjectShape, M, []>; + shape: ObjectShape + ): ObjectShape, M, [], never>; } /** Helper that maps a single constraint into its public `describe(...)` result. */ export type DescribeConstraint = - C extends Assertion - ? AssertionDescriptor - : C extends ObjectShape + C extends Assertion + ? AssertionDescriptor> + : C extends ObjectShape ? ShapeConstraintDescriptor, R> & { readonly unknownKeys: M } : C extends OptionalValidator ? WrapperConstraintDescriptor<'optional', DescribeMaybeMany> From e5d3c728007257f8e59f62ee17c98870acc7083d Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 18:33:07 +0400 Subject: [PATCH 2/4] chore: Updated yarn.lock --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 093ee1f..7742b3a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,10 @@ "vite-plugin-dts": "^4.5.4", "vitest": "^4.0.18" }, + "resolutions": { + "minimatch@npm:10.2.1": "npm:10.2.4", + "minimatch@npm:^9.0.3": "npm:9.0.7" + }, "publishConfig": { "access": "public" }, From 7fd3b4a3ecdbc39c4def9787e21845d2ec02d7f5 Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 18:33:33 +0400 Subject: [PATCH 3/4] chore: Updated yarn.lock --- yarn.lock | 37 ++++++------------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/yarn.lock b/yarn.lock index a97074e..7296622 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1726,13 +1726,6 @@ __metadata: languageName: node linkType: hard -"balanced-match@npm:^1.0.0": - version: 1.0.2 - resolution: "balanced-match@npm:1.0.2" - checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee - languageName: node - linkType: hard - "balanced-match@npm:^4.0.2": version: 4.0.4 resolution: "balanced-match@npm:4.0.4" @@ -1740,15 +1733,6 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^2.0.1": - version: 2.0.1 - resolution: "brace-expansion@npm:2.0.1" - dependencies: - balanced-match: "npm:^1.0.0" - checksum: 10c0/b358f2fe060e2d7a87aa015979ecea07f3c37d4018f8d6deb5bd4c229ad3a0384fe6029bb76cd8be63c81e516ee52d1a0673edbe2023d53a5191732ae3c3e49f - languageName: node - linkType: hard - "brace-expansion@npm:^5.0.2": version: 5.0.4 resolution: "brace-expansion@npm:5.0.4" @@ -3068,16 +3052,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:10.2.1": - version: 10.2.1 - resolution: "minimatch@npm:10.2.1" - dependencies: - brace-expansion: "npm:^5.0.2" - checksum: 10c0/86c3ed013630e820fda00336ee786a03098723b60bfae452de6306708fc83619df40a99dc6ec59c97d14e25b3b3371669a04e5bf508b1b00339b20229c4907d2 - languageName: node - linkType: hard - -"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4": +"minimatch@npm:10.2.4, minimatch@npm:^10.2.2, minimatch@npm:^10.2.4": version: 10.2.4 resolution: "minimatch@npm:10.2.4" dependencies: @@ -3086,12 +3061,12 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.3": - version: 9.0.5 - resolution: "minimatch@npm:9.0.5" +"minimatch@npm:9.0.7": + version: 9.0.7 + resolution: "minimatch@npm:9.0.7" dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10c0/de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed + brace-expansion: "npm:^5.0.2" + checksum: 10c0/e90f8d8dd1dd3e1257b29c8cb31f554e74e2b89d52391b3903b566a28a287b59e2077a25552bc90a31bbbb79113a59e7422c7783ed9f968fe21c2ccb3c67bdef languageName: node linkType: hard From acd12c49bb0f16e66df04e991afb8272fc7c55ec Mon Sep 17 00:00:00 2001 From: Kirill Zaytsev Date: Mon, 9 Mar 2026 19:33:35 +0400 Subject: [PATCH 4/4] ci: Release workflow was switched to trusted publishing --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f57b3ef..a8c2365 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ on: permissions: contents: write + id-token: write concurrency: group: release-${{ github.ref }} @@ -111,6 +112,5 @@ jobs: - name: Publish to npm env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TAG: ${{ inputs.npm_tag }} run: npm publish --access public --tag "$NPM_TAG"