feat(sium): audit cleanup + 2.0 (coercion, formats, combinators, object utils)

Audit: drop dead SIUM_ERRORS catalogue, no-op try/catch in refine steps, unused isPromiseLike/pathKeys re-export, ignored dateValue/timeValue opts; add a sync fast-path to ~standard.validate so sync schemas no longer force the async branch (+untrack workaround) in form/storage consumers; doc fixes ($lib alias, JSDoc, README).

2.0 (all on the s.* facade): coercion (coerceNumber/Boolean/String); string formats (uuid/slug/datetime/ipv4); numeric (finite/positive/nonnegative/multipleOf); transforms (trim/toLowerCase/toUpperCase) + nullish; combinators tuple/record (+ SchemaKind/SiumShape vocabulary + introspection); object utils pick/omit/partial/extend/merge (+ SiumObjectUtilError). 8 new issue codes with es/en.

398 -> 450 tests, 0 type errors, prettier clean. Updated arts/ diagnostics contract + sium README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 25131127bf
commit 37fe04693a

@ -56,12 +56,14 @@ import type { DiagnosticEvent, Diagnostics, Logger } from '$libs/logger';
interfaces or narrowed aliases for individual modules.
- The root logger implementation is `EngineLogger` from `$logger`; it extends the
shared `Logger` contract from `$libs/logger`.
- Artifact code defines `<Artifact>Diagnostics` with
`create<Artifact>Diagnostics(logger?)` and emits catalogued events for
internal diagnostics.
- Artifact code defines `<Artifact>Diagnostics` in a dedicated
`diagnostics.ts` file — the canonical home for the `DiagnosticCatalog`,
`create<Artifact>Diagnostics(logger?)` and `emit<Artifact>Diagnostic(...)`.
Every artifact that emits diagnostics ships this file.
- Diagnostic event names live in the artifact `consts.ts` as
`*_DIAGNOSTIC_EVENTS`. Messages live in `errors.ts` or `consts.ts`, never as
inline strings in runtime logic.
`*_DIAGNOSTIC_EVENTS`. Message strings are **named constants** in `consts.ts`
or `errors.ts` (or co-located in `diagnostics.ts` when only the catalog reads
them) — never inline string literals in the catalog or runtime logic.
- `Diagnostics<TEvent>` always exposes `{ logger, emit(event) }`. The `logger`
property is the common `Logger`, so modules that need an ad-hoc `info` or
`error` still have the full logger without inventing a second interface.

@ -30,6 +30,21 @@ Core, tipos de dominio, lenguajes, Standard Schema e introspeccion estan activos
El adapter `svelte/` todavia no esta portado en este arbol. Por eso la suite recomendada para Sium omite la integracion Svelte hasta que esa capa se migre.
## Novedades 2.0
Ampliacion alineada a lo que el ecosistema Active necesita (formularios, storage, http). NO busca paridad con Zod/Valibot.
- **Refines de formato (string):** `uuid()`, `slug()`, `datetime()` (ISO 8601), `ipv4()`.
- **Refines numericos:** `finite()` (rechaza `Infinity`/`-Infinity`/`NaN`), `positive()` (> 0), `nonnegative()` (>= 0), `multipleOf(n)`.
- **Coercion:** `coerceNumber()`, `coerceBoolean()`, `coerceString()` — hermanos generales de `coerceDate()` para entradas sueltas (inputs HTML, query strings).
- **Standard Schema sincrono:** `schema['~standard'].validate(x)` resuelve de forma SINCRONA cuando el schema no tiene pasos async (devuelve el `Result` directo en vez de `Promise`). SS v1 permite `Result | Promise<Result>`; esto elimina el branch async + el workaround `untrack` en los consumidores (`form-core`, `storage`) para schemas sincronos.
- **Transforms de string:** `trim()`, `toLowerCase()`, `toUpperCase()` para normalizar formularios.
- **Modificador `nullish()`:** acepta `undefined` y `null` (= `optional` + `nullable`).
- **Combinadores `tuple()` y `record()`:** tupla heterogenea de longitud fija + diccionario de claves arbitrarias. Amplian el vocabulario `SchemaKind` (`'tuple'`/`'record'`); `Form.AutoFields` los degrada con un aviso "not supported yet".
- **Utilidades de object:** `pick` / `omit` / `partial` / `extend` / `merge` — derivan un `object()` reusando los campos del original, con tipos precisos via `Pick`/`Omit`/`Partial`.
- **API depurada:** `dateValue()` / `timeValue()` ya no aceptan `opts` (eran ignorados). Para validar granularidad/calendario, compon un `refine` sobre el valor.
- 8 nuevos issue codes (`uuid`, `slug`, `datetime`, `ipv4`, `finite`, `positive`, `nonnegative`, `multiple_of`) con traduccion es/en.
## Importaciones
Sium expone una fachada publica y barrels tematicos.
@ -88,11 +103,11 @@ interface Schema<I = unknown, O = I> {
`validate` envuelve `decode` y devuelve `{ ok: true, value }` o `{ ok: false, issues }`.
`createEngineSium({ logger }).validate(schema, input)` hace lo mismo y, si falla, registra un evento `debug` usando `LOGGER_CATEGORY` y `SIUM_ERRORS.VALIDATION_FAILED`, con `issueCount` e `issues`.
`createEngineSium({ logger }).validate(schema, input)` hace lo mismo y, si falla, emite un diagnóstico `SIUM_DIAGNOSTIC_EVENTS.VALIDATION_FAILED` a nivel `debug` (categoría `SIUM_MODULE`), con `issueCount` e `issues`.
`encode` invierte la salida hacia la forma de entrada. En transforms de una sola direccion actua como identidad para esa parte; usa `codec()` cuando necesites reversibilidad real.
`~standard` permite usar schemas Sium en cualquier consumidor de Standard Schema v1.
`~standard` permite usar schemas Sium en cualquier consumidor de Standard Schema v1. Los schemas sincronos resuelven de forma sincrona (`validate` devuelve el `Result` directo); solo los que tienen pasos async devuelven `Promise`.
`~sium` contiene metadata para UI, debug, docs y herramientas.
@ -111,11 +126,14 @@ Sium cubre cuatro huecos que las librerias externas no resuelven de forma nativa
src/arts/sium/
index.ts fachada publica
engine-sium.ts createEngineSium()
core/ kernel puro TypeScript
engine-resolver.ts, engine-validation.ts
resolucion de mensajes idlangref + runtime de validate/validateSync
consts.ts, errors.ts, diagnostics.ts
SIUM_MODULE + errores tipados (CodeError) + catalogo de logs
core/ kernel puro TypeScript (schemas, combinators, pipe)
types/ tipos de dominio sobre $libs/color y $libs/days
langs/ catalogo de mensajes y fallback de interpolacion
svelte/ adapter Svelte pendiente de port, no presente ahora
examples/ schemas de ejemplo ejecutables/importables
langs/ catalogo de mensajes traducibles + fallback de interpolacion
_examples/ schemas de ejemplo (dev-only, fuera del barril publico)
test/ suite Vitest
```
@ -243,6 +261,7 @@ enumOf(['admin', 'user', 'guest'] as const);
```ts
optional(string());
nullable(string());
nullish(string());
defaulted(number(), () => 18);
```
@ -250,6 +269,8 @@ defaulted(number(), () => 18);
`nullable` permite `null`.
`nullish` permite `undefined` y `null` (combina `optional` + `nullable`).
`defaulted` aplica el valor por defecto en decode cuando el input es `undefined`.
Si el schema interno es async, los wrappers conservan el short-circuit sincronico del sentinel:
@ -262,6 +283,8 @@ si necesite validar el inner async lanza `SiumAsyncSchemaError` en APIs sync.
```ts
object({ name: string() });
array(string());
tuple(string(), number(), boolean());
record(number());
union(string(), number());
discriminated('kind', [
@ -277,6 +300,22 @@ object({ name: string() }, { unknownKeys: 'strict' });
object({ name: string() }, { unknownKeys: 'passthrough' });
```
`tuple(...)` valida posicionalmente y exige la longitud exacta (issue `tuple_length`). `record(schema)` valida un objeto plano de claves arbitrarias cuyos valores comparten `schema`.
### Utilidades de object
Derivan un nuevo `object()` a partir de otro, reusando sus campos:
```ts
pick(User, ['id', 'name']); // solo esos campos
omit(User, ['password']); // todos menos esos
partial(User); // todos los campos opcionales
extend(User, { role: string() }); // anade / sobrescribe campos
merge(Base, Overrides); // combina dos object (gana el segundo)
```
Los tipos resultantes se derivan con `Pick` / `Omit` / `Partial` sobre el schema fuente, asi que siguen siendo precisos. Pasar un schema que no sea `object()` lanza `SiumObjectUtilError`.
### Pipe Y Steps
```ts
@ -316,10 +355,49 @@ regex(/^[a-z0-9-]+$/);
email();
url();
integer();
// formato (string)
uuid();
slug();
datetime(); // ISO 8601
ipv4();
// numericos
finite(); // rechaza Infinity / -Infinity / NaN
positive(); // > 0
nonnegative(); // >= 0
multipleOf(0.5);
```
`min` y `max` funcionan con numeros, strings y arrays. Emiten codigos distintos para valor numerico y longitud.
`finite()` cubre el hueco de `number()`, que acepta `Infinity` por diseno. `positive()` no implica `finite()`: compon ambos para rechazar tambien `Infinity`.
### Coercion
Para entradas sueltas (inputs HTML, query strings, FormData) que llegan como string y necesitan convertirse a un primitivo:
```ts
coerceNumber(); // string | number -> number (rechaza '' y NaN; Infinity pasa, compon con finite())
coerceBoolean(); // string | number | boolean -> boolean ('true/1/yes/on' vs 'false/0/no/off')
coerceString(); // string | number | boolean -> string
```
Son los hermanos generales de `coerceDate()`. Cada uno reporta el `kind` de SALIDA (`coerceNumber()` -> `number`), asi que `Form.AutoFields` renderiza el widget correcto.
### Transforms De String
Normalizadores que cambian el valor en decode (son `transform`, sin `encode`). Aplica despues de `string()`:
```ts
pipe(string(), trim()); // recorta espacios
pipe(string(), toLowerCase()); // minusculas
pipe(string(), toUpperCase()); // mayusculas
pipe(string(), trim(), toLowerCase()); // email normalizado
```
El `kind` sigue siendo `string`, asi que son transparentes para `Form.AutoFields`.
## Tipos De Dominio
Los tipos de dominio viven en `$sium/types` y envuelven las librerias canonicas `$libs/color` y `$libs/days`.
@ -769,7 +847,7 @@ Es esperado mientras el provider UI no este portado. El core de Sium se valida c
## Verificacion Recomendada
```bash
npx vitest run src/arts/sium/test/barrel.test.ts src/arts/sium/test/engine-sium.test.ts src/arts/sium/test/combinators.test.ts src/arts/sium/test/date.test.ts src/arts/sium/test/time.test.ts src/arts/sium/test/color.test.ts src/arts/sium/test/pipe.test.ts src/arts/sium/test/primitives.test.ts src/arts/sium/test/refines.test.ts src/arts/sium/test/schema.test.ts src/arts/sium/test/standard-schema.test.ts src/arts/sium/test/modifiers.test.ts src/arts/sium/test/issue.test.ts src/arts/sium/test/lazy.test.ts src/arts/sium/test/introspect.test.ts src/arts/sium/test/langs.test.ts src/arts/sium/test/resolver.test.ts
npx vitest run src/arts/sium/test/barrel.test.ts src/arts/sium/test/engine-sium.test.ts src/arts/sium/test/combinators.test.ts src/arts/sium/test/date.test.ts src/arts/sium/test/time.test.ts src/arts/sium/test/color.test.ts src/arts/sium/test/pipe.test.ts src/arts/sium/test/primitives.test.ts src/arts/sium/test/refines.test.ts src/arts/sium/test/schema.test.ts src/arts/sium/test/standard-schema.test.ts src/arts/sium/test/modifiers.test.ts src/arts/sium/test/issue.test.ts src/arts/sium/test/lazy.test.ts src/arts/sium/test/introspect.test.ts src/arts/sium/test/langs.test.ts src/arts/sium/test/resolver.test.ts src/arts/sium/test/coerce.test.ts src/arts/sium/test/refines-2.test.ts src/arts/sium/test/transforms.test.ts src/arts/sium/test/tuple.test.ts src/arts/sium/test/record.test.ts src/arts/sium/test/object-utils.test.ts
```
```bash

@ -15,25 +15,10 @@ import type { DateRange } from '$libs/days';
export const bookingFormSchema = pipe(
object({
range: pipe(
dateRange(),
meta({ label: 'Date range' })
),
checkInTime: pipe(
timeValue(),
meta({ label: 'Check-in time' })
),
guests: pipe(
number(),
integer(),
min(1),
max(10),
meta({ label: 'Guests' })
),
notes: pipe(
optional(string()),
meta({ label: 'Notes' })
)
range: pipe(dateRange(), meta({ label: 'Date range' })),
checkInTime: pipe(timeValue(), meta({ label: 'Check-in time' })),
guests: pipe(number(), integer(), min(1), max(10), meta({ label: 'Guests' })),
notes: pipe(optional(string()), meta({ label: 'Notes' }))
}),
refine(
(v) => {

@ -2,24 +2,9 @@ import { object, pipe, meta } from '$sium/core';
import { colorValue, alpha, hue } from '$sium/types';
export const colorCustomizerFormSchema = object({
primary: pipe(
colorValue(),
meta({ label: 'Primary color' })
),
secondary: pipe(
colorValue(),
meta({ label: 'Secondary color' })
),
accent: pipe(
colorValue(),
meta({ label: 'Accent color' })
),
opacity: pipe(
alpha(),
meta({ label: 'Opacity' })
),
customHue: pipe(
hue(),
meta({ label: 'Custom hue' })
)
primary: pipe(colorValue(), meta({ label: 'Primary color' })),
secondary: pipe(colorValue(), meta({ label: 'Secondary color' })),
accent: pipe(colorValue(), meta({ label: 'Accent color' })),
opacity: pipe(alpha(), meta({ label: 'Opacity' })),
customHue: pipe(hue(), meta({ label: 'Custom hue' }))
});

@ -1,14 +1,6 @@
import { object, pipe, string, email, min, meta } from '$sium/core';
export const loginFormSchema = object({
email: pipe(
string(),
email(),
meta({ label: 'Email' })
),
password: pipe(
string(),
min(8),
meta({ label: 'Password' })
)
email: pipe(string(), email(), meta({ label: 'Email' })),
password: pipe(string(), min(8), meta({ label: 'Password' }))
});

@ -1,39 +1,11 @@
import {
object,
boolean,
defaulted,
enumOf,
pipe,
meta
} from '$sium/core';
import { object, boolean, defaulted, enumOf, pipe, meta } from '$sium/core';
export const settingsFormSchema = object({
theme: pipe(
enumOf(['light', 'dark', 'auto']),
meta({ label: 'Theme' })
),
locale: defaulted(
pipe(
enumOf(['es', 'en']),
meta({ label: 'Locale' })
),
() => 'es'
),
theme: pipe(enumOf(['light', 'dark', 'auto']), meta({ label: 'Theme' })),
locale: defaulted(pipe(enumOf(['es', 'en']), meta({ label: 'Locale' })), () => 'es'),
notifications: object({
email: pipe(
boolean(),
meta({ label: 'Email notifications' })
),
push: pipe(
boolean(),
meta({ label: 'Push notifications' })
),
sms: defaulted(
pipe(
boolean(),
meta({ label: 'SMS notifications' })
),
() => false
)
email: pipe(boolean(), meta({ label: 'Email notifications' })),
push: pipe(boolean(), meta({ label: 'Push notifications' })),
sms: defaulted(pipe(boolean(), meta({ label: 'SMS notifications' })), () => false)
})
});

@ -1,27 +1,10 @@
import {
object,
pipe,
string,
boolean,
optional,
refine,
meta
} from '$sium/core';
import { object, pipe, string, boolean, optional, refine, meta } from '$sium/core';
export const signupFormSchema = pipe(
object({
email: pipe(
string(),
meta({ label: 'Email' })
),
password: pipe(
string(),
meta({ label: 'Password' })
),
confirmPassword: pipe(
string(),
meta({ label: 'Confirm password' })
),
email: pipe(string(), meta({ label: 'Email' })),
password: pipe(string(), meta({ label: 'Password' })),
confirmPassword: pipe(string(), meta({ label: 'Confirm password' })),
acceptTerms: pipe(
boolean(),
refine((v) => v === true, {
@ -30,10 +13,7 @@ export const signupFormSchema = pipe(
}),
meta({ label: 'Accept terms' })
),
newsletter: pipe(
optional(boolean()),
meta({ label: 'Subscribe to newsletter' })
)
newsletter: pipe(optional(boolean()), meta({ label: 'Subscribe to newsletter' }))
}),
refine((v) => v.password === v.confirmPassword, {
code: 'password_mismatch',

@ -29,10 +29,7 @@ const multipleQuestion = object({
type: literal('multiple'),
question: pipe(string(), meta({ label: 'Question' })),
options: pipe(array(string()), meta({ label: 'Options' })),
selected: pipe(
enumOf(['a', 'b', 'c', 'd']),
meta({ label: 'Selected' })
)
selected: pipe(enumOf(['a', 'b', 'c', 'd']), meta({ label: 'Selected' }))
});
export const surveyFormSchema = object({

@ -1,20 +1,12 @@
import {
array,
lazy,
min,
object,
optional,
pipe,
string,
type Schema
} from '$sium/core';
import { array, lazy, min, object, optional, pipe, string, type Schema } from '$sium/core';
export type TreeNode = {
label: string;
children?: TreeNode[];
};
export const treeNodeSchema: Schema<TreeNode, TreeNode> = lazy((): Schema<TreeNode, TreeNode> =>
export const treeNodeSchema: Schema<TreeNode, TreeNode> = lazy(
(): Schema<TreeNode, TreeNode> =>
object({
label: pipe(string(), min(1)),
children: optional(array(treeNodeSchema))

@ -56,13 +56,16 @@ export const userFormSchema = object({
),
confirmPassword: pipe(
string(),
refine((value, ctx) => {
refine(
(value, ctx) => {
const root = ctx.rootInput as Record<string, unknown> | undefined;
return value === root?.password;
}, {
},
{
code: 'custom',
message: '#?sium.errors.custom|Confirmation must match the password'
}),
}
),
meta({
label: 'Confirm password'
})

@ -5,3 +5,5 @@ export const SIUM_DIAGNOSTIC_EVENTS = {
VALIDATION_FAILED: 'sium.validation_failed',
RESOLVE_FALLBACK: 'sium.resolve_fallback'
} as const;
export const SIUM_LOG_MSG_VALIDATION_FAILED = `[${SIUM_MODULE}] validation failed.`;

@ -0,0 +1,110 @@
import { issue } from './issue';
import { createSchema } from './schema';
import { actualType } from './internals';
import { SiumValidationError } from '../errors';
import type { Schema } from './types';
/**
* Coercion schemas — accept a loose runtime value (the kind HTML inputs,
* query strings and `FormData` produce) and decode it into a concrete
* primitive. They are the general-purpose siblings of `coerceDate()` in
* `types/date.ts`.
*
* Each is a sync identity-on-output codec: `decode` narrows the loose input,
* `encode` returns the decoded value unchanged (coercion is one-way on the
* input axis — there is no canonical inverse for "which string produced this
* number"). The reported `kind` is the OUTPUT kind, so `Form.AutoFields`
* renders the right widget (a number input for `coerceNumber()`, etc.).
*/
const TRUTHY_STRINGS = new Set(['true', '1', 'yes', 'on']);
const FALSY_STRINGS = new Set(['false', '0', 'no', 'off']);
function coerceTypeError(expected: string, input: unknown): SiumValidationError {
return new SiumValidationError([
issue({
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: { expected, actual: actualType(input) }
})
]);
}
/**
* Coerces a string or number into a number.
*
* Numbers (except `NaN`) pass through; numeric strings are parsed with
* `Number()` after trimming. Empty and non-numeric strings raise a `type`
* issue. `Infinity` is allowed — compose with `finite()` to reject it.
*/
export function coerceNumber(): Schema<string | number, number> {
return createSchema<string | number, number>({
kind: 'number',
decodeImpl: (input) => {
if (typeof input === 'number') {
if (Number.isNaN(input)) throw coerceTypeError('number', input);
return input;
}
if (typeof input === 'string') {
const trimmed = input.trim();
if (trimmed === '') throw coerceTypeError('number', input);
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) throw coerceTypeError('number', input);
return parsed;
}
throw coerceTypeError('number', input);
},
encode: (value) => value
});
}
/**
* Coerces a boolean-ish value into a boolean.
*
* Booleans pass through; `1`/`0` map to `true`/`false`; strings are matched
* case-insensitively against `true/1/yes/on` and `false/0/no/off`. Anything
* else raises a `type` issue.
*/
export function coerceBoolean(): Schema<string | number | boolean, boolean> {
return createSchema<string | number | boolean, boolean>({
kind: 'boolean',
decodeImpl: (input) => {
if (typeof input === 'boolean') return input;
if (typeof input === 'number') {
if (input === 1) return true;
if (input === 0) return false;
throw coerceTypeError('boolean', input);
}
if (typeof input === 'string') {
const normalized = input.trim().toLowerCase();
if (TRUTHY_STRINGS.has(normalized)) return true;
if (FALSY_STRINGS.has(normalized)) return false;
throw coerceTypeError('boolean', input);
}
throw coerceTypeError('boolean', input);
},
encode: (value) => value
});
}
/**
* Coerces a string, number or boolean into a string via `String()`.
*
* Strings pass through; numbers (except `NaN`) and booleans are stringified.
* Anything else raises a `type` issue.
*/
export function coerceString(): Schema<string | number | boolean, string> {
return createSchema<string | number | boolean, string>({
kind: 'string',
decodeImpl: (input) => {
if (typeof input === 'string') return input;
if (typeof input === 'number') {
if (Number.isNaN(input)) throw coerceTypeError('string', input);
return String(input);
}
if (typeof input === 'boolean') return String(input);
throw coerceTypeError('string', input);
},
encode: (value) => value
});
}

@ -58,6 +58,20 @@ export function createUnknownKeysIssue(keys: string[], path: ReadonlyArray<strin
});
}
export function createTupleLengthIssue(
expected: number,
actual: number,
path: ReadonlyArray<string | number>
) {
return issue({
path,
code: 'tuple_length',
message:
'#?sium.errors.tuple_length|Expected a tuple of length {{length}} (received {{actual}})',
params: { length: expected, actual }
});
}
export function createUnionNoMatchIssue(path: ReadonlyArray<string | number>) {
return issue({
path,

@ -1,3 +1,5 @@
export { array } from './array-combinator';
export { object } from './object-combinator';
export { discriminated, union } from './union-combinators';
export { tuple } from './tuple-combinator';
export { record } from './record-combinator';

@ -1,12 +1,14 @@
/** Public core barrel; validate/standard remain embedded in the schema factory from CDX-002. */
export { string, number, boolean, literal, enumOf } from './primitives';
export { optional, nullable, defaulted } from './modifiers';
export { object, array, union, discriminated } from './combinators';
export { optional, nullable, defaulted, nullish } from './modifiers';
export { object, array, union, discriminated, tuple, record } from './combinators';
export { lazy } from './lazy';
export { pick, omit, partial, extend, merge } from './object-utils';
export { serializeSchema, countLeafFields, walkSchema } from './introspect';
export type { SerializedSchema, SerializedShape } from './introspect';
export { pipe, refine, transform, codec, meta } from './pipe';
export { trim, toLowerCase, toUpperCase } from './transforms';
export {
min,
max,
@ -15,16 +17,27 @@ export {
email,
url,
integer,
uuid,
slug,
datetime,
ipv4,
finite,
positive,
nonnegative,
multipleOf,
cssLength,
isCssLength,
CSS_LENGTH_REGEX
} from './refines';
export { coerceNumber, coerceBoolean, coerceString } from './coerce';
export {
SiumAsyncSchemaError,
SiumDiscriminatedUnionError,
SiumValidationError,
SiumObjectUtilError,
isSiumAsyncSchemaError,
isSiumDiscriminatedUnionError,
isSiumObjectUtilError,
isSiumValidationError
} from '../errors';
export type { SiumDiscriminatedUnionFault } from '../errors';

@ -13,17 +13,15 @@ export type SerializedShape =
| { kind: 'truncated'; inner: string }
| { kind: 'object'; unknownKeys: string; fields: Record<string, SerializedSchema> }
| { kind: 'array'; item: SerializedSchema }
| { kind: 'tuple'; members: SerializedSchema[] }
| { kind: 'record'; value: SerializedSchema }
| { kind: 'union'; members: SerializedSchema[] }
| { kind: 'discriminated'; key: string; members: SerializedSchema[] }
| { kind: 'optional' | 'nullable' | 'defaulted'; inner: SerializedSchema }
| { kind: 'enum'; values: Array<string | number> }
| { kind: 'literal'; value: unknown };
function serializeMetaValue(
value: unknown,
seen: WeakSet<object>,
depth: number
): unknown {
function serializeMetaValue(value: unknown, seen: WeakSet<object>, depth: number): unknown {
if (
value === null ||
typeof value === 'string' ||
@ -126,6 +124,24 @@ function serializeSchemaInner(
item: serializeSchemaInner(sium.shape.item, maxDepth, depth + 1, seen)
}
};
case 'tuple':
return {
...base,
shape: {
kind: 'tuple',
members: sium.shape.members.map((member) =>
serializeSchemaInner(member, maxDepth, depth + 1, seen)
)
}
};
case 'record':
return {
...base,
shape: {
kind: 'record',
value: serializeSchemaInner(sium.shape.value, maxDepth, depth + 1, seen)
}
};
case 'union':
return {
...base,
@ -196,7 +212,10 @@ export function countLeafFields(schema: Schema<unknown, unknown>): number {
case 'nullable':
case 'defaulted':
return countLeafFields(shape.inner);
case 'tuple':
return shape.members.reduce((acc, child) => acc + countLeafFields(child), 0);
case 'array':
case 'record':
case 'union':
case 'discriminated':
case 'enum':
@ -228,11 +247,17 @@ export function walkSchema(
case 'array':
walkSchema(shape.item, visit, [...basePath, 0]);
return;
case 'tuple':
shape.members.forEach((member, index) => {
walkSchema(member, visit, [...basePath, index]);
});
return;
case 'optional':
case 'nullable':
case 'defaulted':
walkSchema(shape.inner, visit, basePath);
return;
case 'record':
case 'union':
case 'discriminated':
case 'enum':

@ -66,9 +66,7 @@ export function prependPath(
* - one group -> same reference
* - one non-empty group among empties -> same reference
*/
export function collect(
...groups: ReadonlyArray<ReadonlyArray<Issue>>
): ReadonlyArray<Issue> {
export function collect(...groups: ReadonlyArray<ReadonlyArray<Issue>>): ReadonlyArray<Issue> {
if (groups.length === 0) {
return [];
}

@ -144,3 +144,16 @@ export function defaulted<I, O>(
encode: (value) => schema.encode(value)
});
}
/**
* Wraps a schema so BOTH `undefined` and `null` are accepted on decode and
* preserved on encode — the union of `optional()` and `nullable()`.
*
* Implemented as `nullable(optional(schema))`, so it inherits both sentinel
* short-circuits and reports `wrappers: [...inner, 'optional', 'nullable']`.
*/
export function nullish<I, O>(
schema: Schema<I, O>
): Schema<I | null | undefined, O | null | undefined> {
return nullable(optional(schema));
}

@ -0,0 +1,92 @@
import { object } from './object-combinator';
import { optional } from './modifiers';
import { SiumObjectUtilError } from '../errors';
import type { Schema } from './types';
/**
* Object-schema utilities — derive a new `object()` schema from an existing
* one by reshaping its field set. They read the field map from the source
* schema's introspection (`shape.fields`) and rebuild a fresh `object()`.
*
* The result types are expressed with `Pick` / `Omit` / `Partial` over the
* source `I` / `O`, so they stay precise. A non-object schema is a programmer
* error and throws `SiumObjectUtilError`.
*/
type InferInput<S extends Schema> = S extends Schema<infer I, unknown> ? I : never;
type InferOutput<S extends Schema> = S extends Schema<unknown, infer O> ? O : never;
function requireObjectFields(schema: Schema, util: string): Record<string, Schema> {
const shape = schema['~sium'].shape;
if (schema['~sium'].kind !== 'object' || shape?.kind !== 'object') {
throw new SiumObjectUtilError(util);
}
return shape.fields as Record<string, Schema>;
}
/** Keeps only the given keys, dropping the rest. */
export function pick<I, O, K extends keyof I & keyof O & string>(
schema: Schema<I, O>,
keys: readonly K[]
): Schema<Pick<I, K>, Pick<O, K>> {
const fields = requireObjectFields(schema, 'pick');
const picked: Record<string, Schema> = {};
for (const key of keys) {
if (key in fields) picked[key] = fields[key];
}
return object(picked) as unknown as Schema<Pick<I, K>, Pick<O, K>>;
}
/** Drops the given keys, keeping the rest. */
export function omit<I, O, K extends keyof I & keyof O & string>(
schema: Schema<I, O>,
keys: readonly K[]
): Schema<Omit<I, K>, Omit<O, K>> {
const fields = requireObjectFields(schema, 'omit');
const exclude = new Set<string>(keys);
const kept: Record<string, Schema> = {};
for (const key of Object.keys(fields)) {
if (!exclude.has(key)) kept[key] = fields[key];
}
return object(kept) as unknown as Schema<Omit<I, K>, Omit<O, K>>;
}
/** Makes every field optional (`undefined`-accepting). */
export function partial<I, O>(schema: Schema<I, O>): Schema<Partial<I>, Partial<O>> {
const fields = requireObjectFields(schema, 'partial');
const out: Record<string, Schema> = {};
for (const key of Object.keys(fields)) {
out[key] = fields[key]['~sium'].wrappers.includes('optional')
? fields[key]
: optional(fields[key]);
}
return object(out) as unknown as Schema<Partial<I>, Partial<O>>;
}
/** Adds or overrides fields, returning a wider object schema. */
export function extend<I, O, F extends Record<string, Schema>>(
schema: Schema<I, O>,
fields: F
): Schema<
Omit<I, keyof F> & { [K in keyof F]: InferInput<F[K]> },
Omit<O, keyof F> & { [K in keyof F]: InferOutput<F[K]> }
> {
const base = requireObjectFields(schema, 'extend');
return object({ ...base, ...fields }) as unknown as Schema<
Omit<I, keyof F> & { [K in keyof F]: InferInput<F[K]> },
Omit<O, keyof F> & { [K in keyof F]: InferOutput<F[K]> }
>;
}
/** Merges two object schemas; fields from `b` win on key conflicts. */
export function merge<IA, OA, IB, OB>(
a: Schema<IA, OA>,
b: Schema<IB, OB>
): Schema<Omit<IA, keyof IB> & IB, Omit<OA, keyof OB> & OB> {
const fieldsA = requireObjectFields(a, 'merge');
const fieldsB = requireObjectFields(b, 'merge');
return object({ ...fieldsA, ...fieldsB }) as unknown as Schema<
Omit<IA, keyof IB> & IB,
Omit<OA, keyof OB> & OB
>;
}

@ -1,11 +1,5 @@
import { createSchema } from './schema';
import {
type Ctx,
type MetaAnnotations,
type Schema,
type SchemaEffect,
type Step
} from './types';
import { type Ctx, type MetaAnnotations, type Schema, type SchemaEffect, type Step } from './types';
type UnknownSchema = Schema<unknown, unknown>;
type UnknownStep = Step<unknown, unknown>;

@ -0,0 +1,98 @@
import { prependPath } from './issue';
import { createSchema } from './schema';
import { SiumEncodeExpectsObjectError, SiumValidationError } from '../errors';
import type { Ctx, Issue, Schema } from './types';
import {
createChildCtx,
createTypeIssue,
isPlainObjectRecord,
resolveChildIssues,
throwCollectedIssues
} from './combinator-helpers';
/**
* Creates a record schema — a plain object with arbitrary string keys whose
* values are all validated by `value`. Use it for dictionaries / maps where
* the keys are not known ahead of time (`record(number())`).
*
* Encode delegates value-by-value. A non-object passed to `decode` raises a
* `type` issue; to `encode`, throws `SiumEncodeExpectsObjectError`.
*/
export function record<I, O>(value: Schema<I, O>): Schema<Record<string, I>, Record<string, O>> {
const isAsync = value['~sium'].async;
const decodeSyncRecord = (input: Record<string, I>, ctx: Ctx) => {
if (!isPlainObjectRecord(input)) {
throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]);
}
const output: Record<string, O> = {};
const issueGroups: ReadonlyArray<Issue>[] = [];
for (const key of Object.keys(input)) {
const childPath = [...ctx.path, key];
try {
output[key] = value.decodeSync(input[key], createChildCtx(ctx, key));
} catch (error) {
if (!(error instanceof SiumValidationError)) throw error;
issueGroups.push(resolveChildIssues(error.issues, childPath));
}
}
if (issueGroups.length > 0) throwCollectedIssues(...issueGroups);
return output;
};
const decodeAsyncRecord = async (input: Record<string, I>, ctx: Ctx) => {
if (!isPlainObjectRecord(input)) {
throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]);
}
const output: Record<string, O> = {};
const issueGroups: ReadonlyArray<Issue>[] = [];
for (const key of Object.keys(input)) {
const childPath = [...ctx.path, key];
try {
output[key] = await value.decode(input[key], createChildCtx(ctx, key));
} catch (error) {
if (!(error instanceof SiumValidationError)) throw error;
issueGroups.push(resolveChildIssues(error.issues, childPath));
}
}
if (issueGroups.length > 0) throwCollectedIssues(...issueGroups);
return output;
};
return createSchema({
kind: 'record',
async: isAsync,
shape: {
kind: 'record',
value
},
decodeImpl: isAsync ? decodeAsyncRecord : decodeSyncRecord,
decodeSyncImpl: decodeSyncRecord,
encode: (output) => {
if (!isPlainObjectRecord(output)) {
throw new SiumEncodeExpectsObjectError('record');
}
const result: Record<string, I> = {};
const issueGroups: ReadonlyArray<Issue>[] = [];
for (const key of Object.keys(output)) {
try {
result[key] = value.encode(output[key] as O);
} catch (error) {
if (!(error instanceof SiumValidationError)) throw error;
issueGroups.push(prependPath(error.issues, key));
}
}
if (issueGroups.length > 0) throwCollectedIssues(...issueGroups);
return result;
}
});
}

@ -205,6 +205,10 @@ export function isCssLength(value: unknown): boolean {
* Creates a CSS-length string refine (px / rem / em / % / vw / vh / vmin / vmax /
* ch, non-negative). Compose as `pipe(string(), cssLength())`.
*
* Emits the generic `custom` issue code, not `css_value`: this is the
* length-only sibling of `cssValue()` (`types/css.ts`), which owns the richer
* `css_value` code. Kept distinct so the two validators stay independent.
*
* @example
* ```ts
* const schema = pipe(string(), cssLength());
@ -237,3 +241,159 @@ export function integer(): Step<number, number> {
})
);
}
// ── v2 format refines (string) ──────────────────────────────────────────────
/** UUID — 8-4-4-4-12 hex groups, any version, case-insensitive. */
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** URL-safe slug — lowercase alphanumeric words joined by single hyphens. */
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/** ISO 8601 date-time — `YYYY-MM-DDTHH:MM:SS(.fff)?(Z|±HH:MM)?`. */
const ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
/** Dotted-quad IPv4 with each octet in 0–255. */
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
/**
* Creates a UUID-format string refine (8-4-4-4-12 hex, any version).
*
* @example
* ```ts
* const schema = pipe(string(), uuid());
* ```
*/
export function uuid(): Step<string, string> {
return refine((value) => typeof value === 'string' && UUID_REGEX.test(value), {
code: 'uuid',
message: '#?sium.errors.uuid|Must be a valid UUID',
params: {}
});
}
/**
* Creates a slug-format string refine (lowercase alphanumeric words separated
* by single hyphens; no leading, trailing or doubled hyphens).
*
* @example
* ```ts
* const schema = pipe(string(), slug());
* ```
*/
export function slug(): Step<string, string> {
return refine((value) => typeof value === 'string' && SLUG_REGEX.test(value), {
code: 'slug',
message: '#?sium.errors.slug|Must be a lowercase hyphen-separated slug',
params: {}
});
}
/**
* Creates an ISO 8601 date-time string refine. Validates the lexical shape
* (date + `T` + time, optional fractional seconds and `Z`/offset); use
* `dateValue()` / `timeValue()` for structured calendar values.
*
* @example
* ```ts
* const schema = pipe(string(), datetime());
* ```
*/
export function datetime(): Step<string, string> {
return refine((value) => typeof value === 'string' && ISO_DATETIME_REGEX.test(value), {
code: 'datetime',
message: '#?sium.errors.datetime|Must be an ISO 8601 date-time string',
params: {}
});
}
/**
* Creates an IPv4-address string refine (dotted quad, each octet 0–255).
*
* @example
* ```ts
* const schema = pipe(string(), ipv4());
* ```
*/
export function ipv4(): Step<string, string> {
return refine((value) => typeof value === 'string' && IPV4_REGEX.test(value), {
code: 'ipv4',
message: '#?sium.errors.ipv4|Must be a valid IPv4 address',
params: {}
});
}
// ── v2 numeric refines ──────────────────────────────────────────────────────
function isMultipleOf(value: number, step: number): boolean {
if (step === 0) return value === 0;
const ratio = value / step;
return Math.abs(ratio - Math.round(ratio)) < 1e-9;
}
/**
* Creates a finite-number refine — rejects `Infinity`, `-Infinity` and `NaN`.
* `number()` accepts the infinities by design; compose this to exclude them.
*
* @example
* ```ts
* const schema = pipe(number(), finite());
* ```
*/
export function finite(): Step<number, number> {
return refine((value) => typeof value === 'number' && Number.isFinite(value), {
code: 'finite',
message: '#?sium.errors.finite|Must be a finite number',
params: {}
});
}
/**
* Creates a strictly-positive number refine (value > 0). Does not imply
* `finite()` — compose both to also reject `Infinity`.
*
* @example
* ```ts
* const schema = pipe(number(), positive());
* ```
*/
export function positive(): Step<number, number> {
return refine((value) => typeof value === 'number' && value > 0, {
code: 'positive',
message: '#?sium.errors.positive|Must be greater than 0',
params: {}
});
}
/**
* Creates a non-negative number refine (value >= 0).
*
* @example
* ```ts
* const schema = pipe(number(), nonnegative());
* ```
*/
export function nonnegative(): Step<number, number> {
return refine((value) => typeof value === 'number' && value >= 0, {
code: 'nonnegative',
message: '#?sium.errors.nonnegative|Must be 0 or greater',
params: {}
});
}
/**
* Creates a multiple-of refine (value is an integer multiple of `n`, with a
* small float tolerance so `0.3` passes `multipleOf(0.1)`).
*
* @example
* ```ts
* const schema = pipe(number(), multipleOf(5));
* ```
*/
export function multipleOf(n: number): Step<number, number> {
return refine((value) => typeof value === 'number' && isMultipleOf(value, n), {
code: 'multiple_of',
message: '#?sium.errors.multiple_of|Must be a multiple of {{multiple}}',
params: { multiple: n }
});
}

@ -106,18 +106,20 @@ export function createSchema<I, O>(spec: CreateSchemaSpec<I, O>): Schema<I, O> {
}
};
const toStandardResult = (result: Result<O>) =>
isErr(result) ? { issues: toStandardIssues(result.issues) } : { value: result.value };
const schema: Schema<I, O> = {
'~standard': {
version: 1,
vendor: SIUM_STANDARD_VENDOR,
validate: async (value: unknown) => {
const result = await validate(value as I);
if (isErr(result)) {
return { issues: toStandardIssues(result.issues) };
}
return { value: result.value };
}
// Sync schemas resolve synchronously so Standard-Schema consumers
// (forms, storage) stay on their sync path; only schemas with async
// steps return a Promise. SS v1 permits `Result | Promise<Result>`.
validate: (value: unknown) =>
spec.async === true
? validate(value as I).then(toStandardResult)
: toStandardResult(validateSync(value as I))
},
'~sium': {
kind: spec.kind,

@ -1,2 +1 @@
export { isPromiseLike, pathKeys } from '$libs/standard-schema';
export type { StandardSchemaV1 } from '$libs/standard-schema';

@ -49,27 +49,18 @@ export function refine<T>(
async,
meta: {},
decode: async (input, ctx) => {
try {
const result = await check(input, ctx);
if (result) {
return input;
}
throw new SiumValidationError([createRefineIssue(ctx.path, fail)]);
} catch (error) {
if (error instanceof SiumValidationError) {
throw error;
}
throw error;
}
},
decodeSync: (input, ctx) => {
if (async) {
throw createAsyncStepError();
}
try {
const result = check(input, ctx);
if (isPromiseLike(result)) {
throw createAsyncStepError();
@ -80,13 +71,6 @@ export function refine<T>(
}
throw new SiumValidationError([createRefineIssue(ctx.path, fail)]);
} catch (error) {
if (error instanceof SiumValidationError) {
throw error;
}
throw error;
}
}
};
}

@ -0,0 +1,24 @@
import { transform } from './pipe';
import type { Step } from './types';
/**
* String-normalizing transform steps. They change the value (so they expose no
* `encode`, like every `transform`) and are meant to run after `string()` has
* validated the type: `pipe(string(), trim(), toLowerCase())`. The `kind`
* stays `string`, so they are transparent to `Form.AutoFields`.
*/
/** Trims leading and trailing whitespace. */
export function trim(): Step<string, string> {
return transform<string, string>((value) => value.trim());
}
/** Lowercases the string (locale-independent). */
export function toLowerCase(): Step<string, string> {
return transform<string, string>((value) => value.toLowerCase());
}
/** Uppercases the string (locale-independent). */
export function toUpperCase(): Step<string, string> {
return transform<string, string>((value) => value.toUpperCase());
}

@ -0,0 +1,110 @@
import { prependPath } from './issue';
import { createSchema } from './schema';
import { SiumEncodeExpectsArrayError, SiumValidationError } from '../errors';
import type { Ctx, Issue, Schema } from './types';
import {
createChildCtx,
createTupleLengthIssue,
createTypeIssue,
resolveChildIssues,
throwCollectedIssues
} from './combinator-helpers';
type InferInput<S extends Schema> = S extends Schema<infer I, unknown> ? I : never;
type InferOutput<S extends Schema> = S extends Schema<unknown, infer O> ? O : never;
/**
* Creates a fixed-length tuple schema — a heterogeneous array validated
* positionally. `tuple(string(), number())` accepts exactly `[string, number]`;
* the length must match (a `tuple_length` issue is raised otherwise).
*
* Encode delegates element-by-element. Non-array values passed to `encode`
* are programmer errors and throw `SiumEncodeExpectsArrayError`.
*/
export function tuple<M extends readonly [Schema, ...Schema[]]>(
...members: M
): Schema<{ [K in keyof M]: InferInput<M[K]> }, { [K in keyof M]: InferOutput<M[K]> }> {
const memberList = members as ReadonlyArray<Schema>;
const isAsync = memberList.some((member) => member['~sium'].async);
const guard = (input: unknown, ctx: Ctx): unknown[] => {
if (!Array.isArray(input)) {
throw new SiumValidationError([createTypeIssue('array', input, ctx.path)]);
}
if (input.length !== memberList.length) {
throw new SiumValidationError([
createTupleLengthIssue(memberList.length, input.length, ctx.path)
]);
}
return input;
};
const decodeSyncTuple = (input: { [K in keyof M]: InferInput<M[K]> }, ctx: Ctx) => {
const arr = guard(input, ctx);
const output: unknown[] = [];
const issueGroups: ReadonlyArray<Issue>[] = [];
for (let index = 0; index < memberList.length; index += 1) {
const childPath = [...ctx.path, index];
try {
output[index] = memberList[index].decodeSync(arr[index], createChildCtx(ctx, index));
} catch (error) {
if (!(error instanceof SiumValidationError)) throw error;
issueGroups.push(resolveChildIssues(error.issues, childPath));
}
}
if (issueGroups.length > 0) throwCollectedIssues(...issueGroups);
return output as { [K in keyof M]: InferOutput<M[K]> };
};
const decodeAsyncTuple = async (input: { [K in keyof M]: InferInput<M[K]> }, ctx: Ctx) => {
const arr = guard(input, ctx);
const output: unknown[] = [];
const issueGroups: ReadonlyArray<Issue>[] = [];
for (let index = 0; index < memberList.length; index += 1) {
const childPath = [...ctx.path, index];
try {
output[index] = await memberList[index].decode(arr[index], createChildCtx(ctx, index));
} catch (error) {
if (!(error instanceof SiumValidationError)) throw error;
issueGroups.push(resolveChildIssues(error.issues, childPath));
}
}
if (issueGroups.length > 0) throwCollectedIssues(...issueGroups);
return output as { [K in keyof M]: InferOutput<M[K]> };
};
return createSchema({
kind: 'tuple',
async: isAsync,
shape: {
kind: 'tuple',
members
},
decodeImpl: isAsync ? decodeAsyncTuple : decodeSyncTuple,
decodeSyncImpl: decodeSyncTuple,
encode: (value) => {
if (!Array.isArray(value)) {
throw new SiumEncodeExpectsArrayError();
}
const output: unknown[] = [];
const issueGroups: ReadonlyArray<Issue>[] = [];
for (let index = 0; index < memberList.length; index += 1) {
try {
output[index] = memberList[index].encode(value[index]);
} catch (error) {
if (!(error instanceof SiumValidationError)) throw error;
issueGroups.push(prependPath(error.issues, index));
}
}
if (issueGroups.length > 0) throwCollectedIssues(...issueGroups);
return output as { [K in keyof M]: InferInput<M[K]> };
}
});
}

@ -2,9 +2,9 @@
* # sium — core types
*
* Pure TypeScript — no Svelte runtime, no runes, no DOM. Runnable in Node,
* Bun, Deno, Cloudflare Workers, or a browser. The Svelte adapter lives at
* `$lib/sium/svelte/` and the UI renderer (`Form.AutoFields`) at
* the framework UI form package.
* Bun, Deno, Cloudflare Workers, or a browser. The Svelte adapter
* (`$sium/svelte/`, pending port) and the UI renderer (`Form.AutoFields`)
* live in the framework UI form package.
*
* Contract and design rules are frozen in the module README. Key invariants:
*
@ -44,6 +44,8 @@ export type SchemaKind =
| 'enum'
| 'object'
| 'array'
| 'tuple'
| 'record'
| 'union'
| 'discriminated'
| 'color'
@ -126,6 +128,8 @@ export type SiumShape =
unknownKeys: 'strip' | 'strict' | 'passthrough';
}
| { kind: 'array'; item: Schema }
| { kind: 'tuple'; members: ReadonlyArray<Schema> }
| { kind: 'record'; value: Schema }
| { kind: 'union'; members: ReadonlyArray<Schema> }
| { kind: 'discriminated'; key: string; members: ReadonlyArray<Schema> }
| { kind: 'optional' | 'nullable'; inner: Schema }

@ -6,12 +6,10 @@ import {
type Diagnostics,
type Logger
} from '$libs/logger';
import { SIUM_MODULE, SIUM_DIAGNOSTIC_EVENTS } from './consts';
import { SIUM_MODULE, SIUM_DIAGNOSTIC_EVENTS, SIUM_LOG_MSG_VALIDATION_FAILED } from './consts';
import { SIUM_LOG_MSG_RESOLVE_FALLBACK } from './errors';
import type { Issue } from './core';
const SIUM_LOG_MSG_VALIDATION_FAILED = `[${SIUM_MODULE}] validation failed.`;
export type SiumDiagnosticType =
(typeof SIUM_DIAGNOSTIC_EVENTS)[keyof typeof SIUM_DIAGNOSTIC_EVENTS];

@ -42,8 +42,7 @@ function hasActiveLocaleSource(
langs: EngineLangs | undefined
): langs is EngineLangs & ActiveLocaleSource {
return (
langs !== undefined &&
typeof (langs as Partial<ActiveLocaleSource>).getLocale === 'function'
langs !== undefined && typeof (langs as Partial<ActiveLocaleSource>).getLocale === 'function'
);
}

@ -12,16 +12,38 @@ import {
cssLength,
defaulted,
discriminated,
tuple,
record,
pick,
omit,
partial,
extend,
merge,
email,
enumOf,
integer,
uuid,
slug,
datetime,
ipv4,
finite,
positive,
nonnegative,
multipleOf,
coerceNumber,
coerceBoolean,
coerceString,
lazy,
length,
literal,
max,
meta,
trim,
toLowerCase,
toUpperCase,
min,
nullable,
nullish,
number,
object,
optional,
@ -82,6 +104,13 @@ export interface EngineSium {
readonly array: typeof array;
readonly union: typeof union;
readonly discriminated: typeof discriminated;
readonly tuple: typeof tuple;
readonly record: typeof record;
readonly pick: typeof pick;
readonly omit: typeof omit;
readonly partial: typeof partial;
readonly extend: typeof extend;
readonly merge: typeof merge;
readonly lazy: typeof lazy;
readonly serializeSchema: typeof serializeSchema;
readonly countLeafFields: typeof countLeafFields;
@ -99,6 +128,21 @@ export interface EngineSium {
readonly url: typeof url;
readonly cssLength: typeof cssLength;
readonly integer: typeof integer;
readonly uuid: typeof uuid;
readonly slug: typeof slug;
readonly datetime: typeof datetime;
readonly ipv4: typeof ipv4;
readonly finite: typeof finite;
readonly positive: typeof positive;
readonly nonnegative: typeof nonnegative;
readonly multipleOf: typeof multipleOf;
readonly coerceNumber: typeof coerceNumber;
readonly coerceBoolean: typeof coerceBoolean;
readonly coerceString: typeof coerceString;
readonly trim: typeof trim;
readonly toLowerCase: typeof toLowerCase;
readonly toUpperCase: typeof toUpperCase;
readonly nullish: typeof nullish;
readonly timeValue: typeof timeValue;
readonly timeRange: typeof timeRange;
readonly hour: typeof hour;
@ -163,6 +207,13 @@ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium {
array,
union,
discriminated,
tuple,
record,
pick,
omit,
partial,
extend,
merge,
lazy,
serializeSchema,
countLeafFields,
@ -180,6 +231,21 @@ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium {
url,
cssLength,
integer,
uuid,
slug,
datetime,
ipv4,
finite,
positive,
nonnegative,
multipleOf,
coerceNumber,
coerceBoolean,
coerceString,
trim,
toLowerCase,
toUpperCase,
nullish,
timeValue,
timeRange,
hour,

@ -1,9 +1,5 @@
import { SIUM_DIAGNOSTIC_EVENTS } from './consts';
import {
emitSiumDiagnostic,
issueDiagnosticMeta,
type SiumDiagnostics
} from './diagnostics';
import { emitSiumDiagnostic, issueDiagnosticMeta, type SiumDiagnostics } from './diagnostics';
import type { Result, Schema } from './core';
export interface SiumValidationRuntime {
@ -19,9 +15,7 @@ export interface SiumValidationRuntime {
) => Result<O>;
}
export function createSiumValidationRuntime(
diagnostics: SiumDiagnostics
): SiumValidationRuntime {
export function createSiumValidationRuntime(diagnostics: SiumDiagnostics): SiumValidationRuntime {
const validate: SiumValidationRuntime['validate'] = async (schema, input, ctx) => {
const result = await schema.validate(input, ctx);
if (!result.ok) {

@ -32,14 +32,14 @@ export const SIUM_ERR_ENCODE_EXPECTS_OBJECT: ErrCode = errCode(SIUM_ERR_ENCODE,
export const SIUM_ERR_ENCODE_EXPECTS_ARRAY: ErrCode = errCode(SIUM_ERR_ENCODE, 'expects_array');
export const SIUM_ERR_ENCODE_NO_MATCH: ErrCode = errCode(SIUM_ERR_ENCODE, 'no_match');
export const SIUM_ERR_LAZY_RESOLVING: ErrCode = errCode(SIUM_ERR, 'lazy_resolving');
export const SIUM_ERR_OBJECT_UTIL: ErrCode = errCode(SIUM_ERR, 'object_util');
// ── Error messages ─────────────────────────────────────────────────────
export const SIUM_ERROR_MESSAGES: ErrorMessages = {
[SIUM_ERR_VALIDATION]: (issues: ReadonlyArray<Issue>): string =>
`[${SIUM_MODULE}] validation failed with ${issues.length} issue(s)`,
[SIUM_ERR_ASYNC_SCHEMA]:
`[${SIUM_MODULE}] schema contains async steps — use decode() / validate() instead of decodeSync / validateSync.`,
[SIUM_ERR_ASYNC_SCHEMA]: `[${SIUM_MODULE}] schema contains async steps — use decode() / validate() instead of decodeSync / validateSync.`,
[SIUM_ERR_DISCRIMINATED_UNION_MEMBER_NOT_OBJECT]: (key: string): string =>
`[${SIUM_MODULE}] discriminated(${key}) members must be object() schemas`,
[SIUM_ERR_DISCRIMINATED_UNION_MEMBER_MISSING_KEY]: (key: string): string =>
@ -48,12 +48,12 @@ export const SIUM_ERROR_MESSAGES: ErrorMessages = {
`[${SIUM_MODULE}] discriminated(${key}) members must use literal() on the discriminator field`,
[SIUM_ERR_ENCODE_EXPECTS_OBJECT]: (combinator: string): string =>
`[${SIUM_MODULE}] ${combinator}().encode expects a plain object value`,
[SIUM_ERR_ENCODE_EXPECTS_ARRAY]:
`[${SIUM_MODULE}] array().encode expects an array value`,
[SIUM_ERR_ENCODE_EXPECTS_ARRAY]: `[${SIUM_MODULE}] array().encode expects an array value`,
[SIUM_ERR_ENCODE_NO_MATCH]: (key: string): string =>
`[${SIUM_MODULE}] discriminated().encode could not match discriminator ${key}`,
[SIUM_ERR_LAZY_RESOLVING]:
`[${SIUM_MODULE}] lazy() cannot decode, encode, or validate while its schema thunk is still resolving`
[SIUM_ERR_LAZY_RESOLVING]: `[${SIUM_MODULE}] lazy() cannot decode, encode, or validate while its schema thunk is still resolving`,
[SIUM_ERR_OBJECT_UTIL]: (util: string): string =>
`[${SIUM_MODULE}] ${util}() expects an object() schema`
};
// ── Error classes ──────────────────────────────────────────────────────
@ -71,9 +71,7 @@ export class SiumValidationError extends CodeError {
readonly issues: ReadonlyArray<Issue>;
constructor(issues: ReadonlyArray<Issue>, message?: string) {
const fn = SIUM_ERROR_MESSAGES[SIUM_ERR_VALIDATION] as (
issues: ReadonlyArray<Issue>
) => string;
const fn = SIUM_ERROR_MESSAGES[SIUM_ERR_VALIDATION] as (issues: ReadonlyArray<Issue>) => string;
super(SIUM_ERR_VALIDATION, { message: message ?? fn(issues) });
this.issues = issues;
}
@ -217,27 +215,29 @@ export function isSiumLazyResolvingError(value: unknown): value is SiumLazyResol
return value instanceof SiumLazyResolvingError;
}
// ── Legacy string catalogue (non-error log/diagnostic messages) ────────
/**
* Thrown by the object utilities (`pick` / `omit` / `partial` / `extend` /
* `merge`) when given a schema that is not an `object()`. Always a programmer
* error — the utilities only reshape object schemas.
*/
export class SiumObjectUtilError extends CodeError {
readonly util: string;
constructor(util: string) {
const fn = SIUM_ERROR_MESSAGES[SIUM_ERR_OBJECT_UTIL] as (util: string) => string;
super(SIUM_ERR_OBJECT_UTIL, { message: fn(util) });
this.util = util;
}
}
export function isSiumObjectUtilError(value: unknown): value is SiumObjectUtilError {
return value instanceof SiumObjectUtilError;
}
// ── Non-error log message ──────────────────────────────────────────────
//
// These strings are referenced by sium runtime code that is NOT thrown
// (lazy access guard, encode-time invariants in combinators). They live
// here for now until each of those sites is migrated to a typed error.
// Diagnostic log string used by sium runtime code that is NOT thrown (the
// resolve-fallback warning emitted when a langs lookup fails). Every thrown
// programmer error uses the typed CodeError classes above.
export const SIUM_LOG_MSG_RESOLVE_FALLBACK = `[${SIUM_MODULE}] falling back while resolving Sium message.`;
export const SIUM_ERRORS = {
OBJECT_ENCODE_EXPECTS_PLAIN_OBJECT: 'object().encode expects a plain object value',
ARRAY_ENCODE_EXPECTS_ARRAY: 'array().encode expects an array value',
LAZY_RESOLVING_ACCESS:
'lazy() cannot decode, encode, or validate while its schema thunk is still resolving',
DISCRIMINATED_MEMBER_NOT_OBJECT: (key: string): string =>
`discriminated(${key}) members must be object() schemas`,
DISCRIMINATED_MEMBER_MISSING_KEY: (key: string): string =>
`discriminated(${key}) members must declare the discriminator field`,
DISCRIMINATED_MEMBER_NOT_LITERAL: (key: string): string =>
`discriminated(${key}) members must use literal() on the discriminator field`,
DISCRIMINATED_ENCODE_EXPECTS_PLAIN_OBJECT:
'discriminated().encode expects a plain object value',
DISCRIMINATED_ENCODE_NO_MATCH: (key: string): string =>
`discriminated().encode could not match discriminator ${key}`
} as const;

@ -21,9 +21,9 @@ export {
SIUM_ERR_ENCODE_EXPECTS_OBJECT,
SIUM_ERR_ENCODE_NO_MATCH,
SIUM_ERR_LAZY_RESOLVING,
SIUM_ERR_OBJECT_UTIL,
SIUM_ERR_VALIDATION,
SIUM_ERROR_MESSAGES,
SIUM_ERRORS,
SIUM_LOG_MSG_RESOLVE_FALLBACK,
SiumAsyncSchemaError,
SiumDiscriminatedUnionError,
@ -31,6 +31,7 @@ export {
SiumEncodeExpectsObjectError,
SiumEncodeNoMatchError,
SiumLazyResolvingError,
SiumObjectUtilError,
SiumValidationError,
isSiumAsyncSchemaError,
isSiumDiscriminatedUnionError,
@ -38,6 +39,7 @@ export {
isSiumEncodeExpectsObjectError,
isSiumEncodeNoMatchError,
isSiumLazyResolvingError,
isSiumObjectUtilError,
isSiumValidationError
} from './errors';
export type { SiumDiscriminatedUnionFault } from './errors';
@ -52,10 +54,18 @@ export {
optional,
nullable,
defaulted,
nullish,
object,
array,
union,
discriminated,
tuple,
record,
pick,
omit,
partial,
extend,
merge,
lazy,
serializeSchema,
countLeafFields,
@ -65,6 +75,9 @@ export {
transform,
codec,
meta,
trim,
toLowerCase,
toUpperCase,
min,
max,
length,
@ -129,13 +142,7 @@ export {
CSS_FREQUENCY_UNITS,
CSS_FLEX_UNITS
} from './types';
export type {
CssUnit,
CssValueParts,
CssDimension,
CssKeyword,
CssValueOptions
} from './types';
export type { CssUnit, CssValueParts, CssDimension, CssKeyword, CssValueOptions } from './types';
// langs
export { ISSUE_CODES, resolve, siumLangs } from './langs';

@ -22,11 +22,20 @@ export const ISSUE_CODES = [
'email',
'url',
'integer',
'uuid',
'slug',
'datetime',
'ipv4',
'finite',
'positive',
'nonnegative',
'multiple_of',
'css_value',
'custom',
'range_order',
// combinators
'unknown_keys',
'tuple_length',
'union_no_match',
'discriminated_missing_key',
'discriminated_unknown_value'

@ -102,6 +102,38 @@ export const siumLangs = {
es: 'Debe ser un entero (se recibió {{actual}})',
en: 'Must be an integer (received {{actual}})'
},
uuid: {
es: 'Debe ser un UUID válido',
en: 'Must be a valid UUID'
},
slug: {
es: 'Debe ser un slug en minúsculas separado por guiones',
en: 'Must be a lowercase hyphen-separated slug'
},
datetime: {
es: 'Debe ser una fecha-hora ISO 8601',
en: 'Must be an ISO 8601 date-time string'
},
ipv4: {
es: 'Debe ser una dirección IPv4 válida',
en: 'Must be a valid IPv4 address'
},
finite: {
es: 'Debe ser un número finito',
en: 'Must be a finite number'
},
positive: {
es: 'Debe ser mayor que 0',
en: 'Must be greater than 0'
},
nonnegative: {
es: 'Debe ser 0 o mayor',
en: 'Must be 0 or greater'
},
multiple_of: {
es: 'Debe ser múltiplo de {{multiple}}',
en: 'Must be a multiple of {{multiple}}'
},
css_value: {
es: 'Debe ser un valor CSS válido ({{units}})',
en: 'Must be a valid CSS value ({{units}})'
@ -118,6 +150,10 @@ export const siumLangs = {
es: 'Claves desconocidas: {{keys}}',
en: 'Unknown keys: {{keys}}'
},
tuple_length: {
es: 'Esperaba una tupla de longitud {{length}} (se recibió {{actual}})',
en: 'Expected a tuple of length {{length}} (received {{actual}})'
},
union_no_match: {
es: 'No coincide con ninguna variante',
en: 'Does not match any variant'

@ -4,7 +4,7 @@
*
* @example
* ```ts
* resolve('Expected {expected} but received {actual}', { expected: 'string', actual: 'number' })
* resolve('Expected {{expected}} but received {{actual}}', { expected: 'string', actual: 'number' })
* // → 'Expected string but received number'
* ```
*/

@ -99,7 +99,9 @@ describe('core barrel type exports', () => {
};
const result: ResultAlias = {
ok: false,
issues: [{ path: [], code: 'custom', message: '#?sium.errors.custom|Error' } satisfies IssueAlias]
issues: [
{ path: [], code: 'custom', message: '#?sium.errors.custom|Error' } satisfies IssueAlias
]
};
expectTypeOf<SchemaAlias>().toEqualTypeOf<Schema<string, number>>();

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { coerceBoolean, coerceNumber, coerceString } from '../core';
describe('coerceNumber()', () => {
const schema = coerceNumber();
it('passes through numbers', () => {
expect(schema.decodeSync(42)).toBe(42);
expect(schema.decodeSync(-3.5)).toBe(-3.5);
});
it('parses numeric strings (trimmed)', () => {
expect(schema.decodeSync('42')).toBe(42);
expect(schema.decodeSync(' 3.14 ')).toBe(3.14);
expect(schema.decodeSync('-7')).toBe(-7);
});
it('rejects empty / non-numeric strings and NaN with a type issue', () => {
for (const bad of ['', ' ', 'abc', '12px']) {
const result = schema.validateSync(bad);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.issues[0].code).toBe('type');
}
expect(schema.validateSync(Number.NaN).ok).toBe(false);
});
it('reports the output kind (number) and encodes as identity', () => {
expect(schema['~sium'].kind).toBe('number');
expect(schema.encode(5)).toBe(5);
});
});
describe('coerceBoolean()', () => {
const schema = coerceBoolean();
it('passes through booleans', () => {
expect(schema.decodeSync(true)).toBe(true);
expect(schema.decodeSync(false)).toBe(false);
});
it('coerces 1/0 and truthy/falsy strings (case-insensitive, trimmed)', () => {
expect(schema.decodeSync(1)).toBe(true);
expect(schema.decodeSync(0)).toBe(false);
for (const value of ['true', 'TRUE', '1', 'yes', 'on', ' On ']) {
expect(schema.decodeSync(value)).toBe(true);
}
for (const value of ['false', 'FALSE', '0', 'no', 'off']) {
expect(schema.decodeSync(value)).toBe(false);
}
});
it('rejects ambiguous values', () => {
for (const bad of ['maybe', '2', 2, '']) {
expect(schema.validateSync(bad).ok).toBe(false);
}
});
it('reports the output kind (boolean)', () => {
expect(schema['~sium'].kind).toBe('boolean');
});
});
describe('coerceString()', () => {
const schema = coerceString();
it('passes through strings and stringifies number / boolean', () => {
expect(schema.decodeSync('hi')).toBe('hi');
expect(schema.decodeSync(42)).toBe('42');
expect(schema.decodeSync(true)).toBe('true');
expect(schema.decodeSync(false)).toBe('false');
});
it('rejects NaN', () => {
expect(schema.validateSync(Number.NaN).ok).toBe(false);
});
it('reports the output kind (string)', () => {
expect(schema['~sium'].kind).toBe('string');
});
});

@ -238,4 +238,3 @@ describe('colorValue()', () => {
expect(colorValue().decodeSync(shorthand as any)).toBe(shorthand);
});
});

@ -1,12 +1,6 @@
import { describe, expect, it } from 'vitest';
import { pipe, string } from '../core';
import {
cssValue,
parseCssValue,
formatCssValue,
isCssValue,
CSS_UNITS
} from '../types/css';
import { cssValue, parseCssValue, formatCssValue, isCssValue, CSS_UNITS } from '../types/css';
describe('parseCssValue()', () => {
it('parses a px dimension', () => {

@ -246,4 +246,3 @@ describe('coerceDate()', () => {
expect(coerceDate().encode(dv)).toBe(dv);
});
});

@ -1,7 +1,7 @@
import { describe, expect, expectTypeOf, it } from 'vitest';
import { createEngineLangs } from '$langs';
import { createEngineLogger, LogLevel } from '$logger';
import { SIUM_MODULE, SIUM_ERRORS, createEngineSium, type EngineSium } from '..';
import { SIUM_MODULE, createEngineSium, type EngineSium } from '..';
import type { Issue, Schema } from '../core';
import { siumLangs } from '../langs';

@ -6,11 +6,7 @@ import {
SiumValidationError,
type Issue
} from '../index.ts';
import {
SIUM_ERR,
SIUM_ERR_ASYNC_SCHEMA,
SIUM_ERR_VALIDATION
} from '../errors.ts';
import { SIUM_ERR, SIUM_ERR_ASYNC_SCHEMA, SIUM_ERR_VALIDATION } from '../errors.ts';
import { CodeError, isCodeError, matches } from '$libs/errs';
const ISSUE: Issue = {

@ -1,5 +1,15 @@
import { describe, expect, it } from 'vitest';
import { array, lazy, number, object, optional, pipe, serializeSchema, string, walkSchema } from '../core';
import {
array,
lazy,
number,
object,
optional,
pipe,
serializeSchema,
string,
walkSchema
} from '../core';
import type { Schema } from '../core';
describe('serializeSchema()', () => {
@ -42,7 +52,8 @@ describe('serializeSchema()', () => {
);
const serialized = serializeSchema(TreeSchema, 1);
const children = serialized.shape?.kind === 'object' ? serialized.shape.fields.children : undefined;
const children =
serialized.shape?.kind === 'object' ? serialized.shape.fields.children : undefined;
expect(children?.shape).toEqual({
kind: 'truncated',

@ -236,4 +236,3 @@ describe('collect()', () => {
expect(collect(empty, groupA, empty)).toBe(groupA);
});
});

@ -18,17 +18,26 @@ describe('ISSUE_CODES', () => {
'email',
'url',
'integer',
'uuid',
'slug',
'datetime',
'ipv4',
'finite',
'positive',
'nonnegative',
'multiple_of',
'css_value',
'custom',
'range_order',
'unknown_keys',
'tuple_length',
'union_no_match',
'discriminated_missing_key',
'discriminated_unknown_value'
] as const;
it('contains exactly the 19 codes in declaration order, no duplicates', () => {
expect(ISSUE_CODES).toHaveLength(19);
it('contains exactly the 28 codes in declaration order, no duplicates', () => {
expect(ISSUE_CODES).toHaveLength(28);
expect([...ISSUE_CODES]).toEqual(expectedCodes);
expect(new Set(ISSUE_CODES).size).toBe(ISSUE_CODES.length);
});
@ -48,10 +57,19 @@ describe('ISSUE_CODES', () => {
| 'email'
| 'url'
| 'integer'
| 'uuid'
| 'slug'
| 'datetime'
| 'ipv4'
| 'finite'
| 'positive'
| 'nonnegative'
| 'multiple_of'
| 'css_value'
| 'custom'
| 'range_order'
| 'unknown_keys'
| 'tuple_length'
| 'union_no_match'
| 'discriminated_missing_key'
| 'discriminated_unknown_value'
@ -63,9 +81,9 @@ describe('ISSUE_CODES', () => {
describe('siumLangs schema', () => {
const errors = siumLangs.errors;
it('has exactly 19 keys — one per IssueCode', () => {
it('has exactly 28 keys — one per IssueCode', () => {
const keys = Object.keys(errors);
expect(keys).toHaveLength(19);
expect(keys).toHaveLength(28);
for (const code of ISSUE_CODES) {
expect(errors).toHaveProperty(code);
}
@ -105,9 +123,11 @@ describe('siumLangs schema', () => {
});
describe('siumLangs.errors.length pluralization', () => {
const lengthEntry = siumLangs.errors.length as (
params: { length: number; actual: number; count: number }
) => { en?: string; es?: string };
const lengthEntry = siumLangs.errors.length as (params: {
length: number;
actual: number;
count: number;
}) => { en?: string; es?: string };
it('EN: singular when count=1', () => {
const result = lengthEntry({ length: 1, actual: 0, count: 1 });

@ -160,7 +160,7 @@ describe('lazy()', () => {
expect(schema['~standard'].version).toBe(1);
expect(schema['~standard'].vendor).toBe('sium');
await expect(schema['~standard'].validate('ok')).resolves.toEqual({ value: 'ok' });
expect(await schema['~standard'].validate('ok')).toEqual({ value: 'ok' });
});
it('preserves validation issue paths from recursive objects', async () => {

@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import {
extend,
isSiumObjectUtilError,
merge,
number,
object,
omit,
partial,
pick,
string
} from '../core';
const User = object({
id: string(),
name: string(),
age: number()
});
describe('pick()', () => {
const schema = pick(User, ['id', 'name']);
it('keeps only the listed fields', () => {
expect(schema.decodeSync({ id: 'x', name: 'Ada' })).toEqual({ id: 'x', name: 'Ada' });
expect(schema['~sium'].kind).toBe('object');
});
it('still validates the kept fields', () => {
expect(schema.validateSync({ id: 'x', name: 5 as never }).ok).toBe(false);
});
});
describe('omit()', () => {
it('drops the listed fields', () => {
const schema = omit(User, ['age']);
expect(schema.decodeSync({ id: 'x', name: 'Ada' })).toEqual({ id: 'x', name: 'Ada' });
});
});
describe('partial()', () => {
const schema = partial(User);
it('accepts a fully empty object and a subset', () => {
expect(schema.decodeSync({})).toEqual({});
expect(schema.decodeSync({ id: 'x' })).toEqual({ id: 'x' });
});
it('marks each field optional in introspection', () => {
const shape = schema['~sium'].shape;
expect(shape?.kind).toBe('object');
if (shape?.kind === 'object') {
expect(shape.fields.id['~sium'].wrappers).toContain('optional');
}
});
});
describe('extend()', () => {
it('adds new fields', () => {
const schema = extend(User, { email: string() });
expect(schema.decodeSync({ id: 'x', name: 'Ada', age: 9, email: 'a@b.com' })).toEqual({
id: 'x',
name: 'Ada',
age: 9,
email: 'a@b.com'
});
});
});
describe('merge()', () => {
it('combines two object schemas (b wins on conflict)', () => {
const a = object({ x: string(), shared: string() });
const b = object({ y: number(), shared: number() });
const schema = merge(a, b);
expect(schema.decodeSync({ x: 'a', y: 1, shared: 2 })).toEqual({ x: 'a', y: 1, shared: 2 });
});
});
describe('object-util guard', () => {
it('throws SiumObjectUtilError on a non-object schema', () => {
const callPick = pick as unknown as (schema: unknown, keys: string[]) => unknown;
let caught: unknown;
try {
callPick(string(), ['length']);
} catch (error) {
caught = error;
}
expect(isSiumObjectUtilError(caught)).toBe(true);
});
});

@ -399,4 +399,3 @@ describe('async propagation in pipe()', () => {
await expect(schema.decode('x')).resolves.toBe('X');
});
});

@ -296,10 +296,10 @@ describe('primitive introspection and standard interop', () => {
it('bridges string() through Standard Schema v1', async () => {
const schema = string();
await expect(schema['~standard'].validate('hello')).resolves.toEqual({
expect(await schema['~standard'].validate('hello')).toEqual({
value: 'hello'
});
await expect(schema['~standard'].validate(42)).resolves.toEqual({
expect(await schema['~standard'].validate(42)).toEqual({
issues: [
{
code: 'type',
@ -311,4 +311,3 @@ describe('primitive introspection and standard interop', () => {
});
});
});

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { number, record, string } from '../core';
describe('record()', () => {
const schema = record(number());
it('decodes an object of uniform values (including empty)', () => {
expect(schema.decodeSync({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
expect(schema.decodeSync({})).toEqual({});
});
it('validates each value and reports the key path', () => {
const result = schema.validateSync({ a: 1, b: 'nope' as never });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues[0].path).toEqual(['b']);
expect(result.issues[0].code).toBe('type');
}
});
it('rejects non-objects and arrays with a type issue', () => {
expect(schema.validateSync([] as never).ok).toBe(false);
expect(schema.validateSync(null as never).ok).toBe(false);
});
it('exposes record introspection', () => {
expect(schema['~sium'].kind).toBe('record');
expect(schema['~sium'].shape).toMatchObject({ kind: 'record' });
});
it('encodes value-by-value (identity for primitives)', () => {
const codec = record(string());
expect(codec.encode({ x: 'a', y: 'b' })).toEqual({ x: 'a', y: 'b' });
});
});

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import {
datetime,
finite,
ipv4,
multipleOf,
nonnegative,
number,
pipe,
positive,
slug,
string,
uuid
} from '../core';
import type { Schema } from '../core';
/** Returns the first issue code, or `null` when the value passes. */
function codeOf<I, O>(schema: Schema<I, O>, value: I): string | null {
const result = schema.validateSync(value);
return result.ok ? null : result.issues[0].code;
}
describe('uuid()', () => {
const schema = pipe(string(), uuid());
it('accepts valid UUIDs in any case', () => {
expect(schema.validateSync('123e4567-e89b-12d3-a456-426614174000').ok).toBe(true);
expect(schema.validateSync('123E4567-E89B-12D3-A456-426614174000').ok).toBe(true);
});
it('rejects malformed UUIDs with code uuid', () => {
expect(codeOf(schema, 'not-a-uuid')).toBe('uuid');
expect(codeOf(schema, '123e4567e89b12d3a456426614174000')).toBe('uuid');
});
});
describe('slug()', () => {
const schema = pipe(string(), slug());
it('accepts lowercase hyphen-separated slugs', () => {
expect(schema.validateSync('hello-world').ok).toBe(true);
expect(schema.validateSync('a1-b2-c3').ok).toBe(true);
});
it('rejects uppercase, spaces and edge / doubled hyphens', () => {
for (const bad of ['Hello', 'hello world', '-x', 'x-', 'a--b', '']) {
expect(codeOf(schema, bad)).toBe('slug');
}
});
});
describe('datetime()', () => {
const schema = pipe(string(), datetime());
it('accepts ISO 8601 date-times (plain, Z, offset)', () => {
for (const value of [
'2026-06-14T10:30:00',
'2026-06-14T10:30:00.123Z',
'2026-06-14T10:30:00+02:00'
]) {
expect(schema.validateSync(value).ok).toBe(true);
}
});
it('rejects date-only and malformed strings with code datetime', () => {
for (const bad of ['2026-06-14', '10:30:00', 'not-a-date']) {
expect(codeOf(schema, bad)).toBe('datetime');
}
});
});
describe('ipv4()', () => {
const schema = pipe(string(), ipv4());
it('accepts valid dotted-quad addresses', () => {
for (const value of ['0.0.0.0', '192.168.1.1', '255.255.255.255']) {
expect(schema.validateSync(value).ok).toBe(true);
}
});
it('rejects out-of-range and malformed addresses with code ipv4', () => {
for (const bad of ['256.0.0.1', '1.2.3', '1.2.3.4.5', 'abc']) {
expect(codeOf(schema, bad)).toBe('ipv4');
}
});
});
describe('finite()', () => {
const schema = pipe(number(), finite());
it('accepts finite numbers', () => {
expect(schema.validateSync(0).ok).toBe(true);
expect(schema.validateSync(-12.5).ok).toBe(true);
});
it('rejects the infinities with code finite', () => {
expect(codeOf(schema, Number.POSITIVE_INFINITY)).toBe('finite');
expect(codeOf(schema, Number.NEGATIVE_INFINITY)).toBe('finite');
});
});
describe('positive()', () => {
const schema = pipe(number(), positive());
it('accepts > 0 and rejects 0 / negatives with code positive', () => {
expect(schema.validateSync(0.1).ok).toBe(true);
expect(codeOf(schema, 0)).toBe('positive');
expect(codeOf(schema, -1)).toBe('positive');
});
});
describe('nonnegative()', () => {
const schema = pipe(number(), nonnegative());
it('accepts >= 0 and rejects negatives with code nonnegative', () => {
expect(schema.validateSync(0).ok).toBe(true);
expect(schema.validateSync(5).ok).toBe(true);
expect(codeOf(schema, -0.5)).toBe('nonnegative');
});
});
describe('multipleOf()', () => {
it('accepts integer multiples, with float tolerance', () => {
expect(pipe(number(), multipleOf(5)).validateSync(15).ok).toBe(true);
expect(pipe(number(), multipleOf(0.1)).validateSync(0.3).ok).toBe(true);
});
it('rejects non-multiples with code multiple_of and params.multiple', () => {
const result = pipe(number(), multipleOf(5)).validateSync(7);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues[0].code).toBe('multiple_of');
expect(result.issues[0].params).toEqual({ multiple: 5 });
}
});
});

@ -87,9 +87,7 @@ describe('resolve()', () => {
expected: 'user,guest'
}
)
).toBe(
'Unknown value "admin" for discriminator "type" (expected one of user,guest)'
);
).toBe('Unknown value "admin" for discriminator "type" (expected one of user,guest)');
});
});
});

@ -143,10 +143,10 @@ describe('createSchema()', () => {
encode: (value: string) => value
});
await expect(successSchema['~standard'].validate('hello')).resolves.toEqual({
expect(await successSchema['~standard'].validate('hello')).toEqual({
value: 'HELLO'
});
await expect(failureSchema['~standard'].validate('hello')).resolves.toEqual({
expect(await failureSchema['~standard'].validate('hello')).toEqual({
issues: [
{
code: 'custom',
@ -157,4 +157,3 @@ describe('createSchema()', () => {
});
});
});

@ -8,6 +8,7 @@ import {
object,
optional,
pipe,
refine,
string
} from '../core';
import type { StandardSchemaV1 } from '../core';
@ -101,13 +102,15 @@ describe('Standard Schema validate() roundtrips', () => {
});
it('returns { value } on type schema success', async () => {
await expect(dateValue()['~standard'].validate(sampleDate)).resolves.toEqual({
// Domain-value schemas are sync, so their SS adapter resolves
// synchronously now — `await` still works (no-op on a plain value).
expect(await dateValue()['~standard'].validate(sampleDate)).toEqual({
value: sampleDate
});
await expect(timeValue()['~standard'].validate(sampleTime)).resolves.toEqual({
expect(await timeValue()['~standard'].validate(sampleTime)).toEqual({
value: sampleTime
});
await expect(colorValue()['~standard'].validate(sampleColor)).resolves.toEqual({
expect(await colorValue()['~standard'].validate(sampleColor)).toEqual({
value: sampleColor
});
});
@ -162,3 +165,35 @@ describe('Standard Schema validate() roundtrips', () => {
expectTypeOf<Output>().toEqualTypeOf<string>();
});
});
describe('Standard Schema validate() sync fast-path', () => {
it('resolves synchronously for a sync schema (success)', () => {
const result = string()['~standard'].validate('hi');
expect(result).not.toBeInstanceOf(Promise);
expect(result).toEqual({ value: 'hi' });
});
it('resolves synchronously for a sync schema (failure)', () => {
const result = number()['~standard'].validate('nope');
expect(result).not.toBeInstanceOf(Promise);
expect(result).toHaveProperty('issues');
});
it('returns a Promise for a schema with async steps', async () => {
const asyncSchema = pipe(
string(),
refine(async (value: string) => value.length > 0, {
code: 'custom',
message: '#?sium.errors.custom|must not be empty'
})
);
expect(asyncSchema['~sium'].async).toBe(true);
const result = asyncSchema['~standard'].validate('hi');
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toEqual({ value: 'hi' });
});
});

@ -22,7 +22,9 @@ describe('hour()', () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues[0].code).toBe('max_value');
expect(result.issues[0].message).toBe('#?sium.errors.max_value|Must be at most {{max}} (received {{actual}})');
expect(result.issues[0].message).toBe(
'#?sium.errors.max_value|Must be at most {{max}} (received {{actual}})'
);
}
});
@ -32,7 +34,9 @@ describe('hour()', () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues[0].code).toBe('min_value');
expect(result.issues[0].message).toBe('#?sium.errors.min_value|Must be at least {{min}} (received {{actual}})');
expect(result.issues[0].message).toBe(
'#?sium.errors.min_value|Must be at least {{min}} (received {{actual}})'
);
}
});
@ -270,4 +274,3 @@ describe('timeRange()', () => {
expect(schema.encode(r)).toBe(r);
});
});

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { nullish, number, pipe, string, toLowerCase, toUpperCase, trim } from '../core';
describe('trim()', () => {
it('trims surrounding whitespace on decode', () => {
const schema = pipe(string(), trim());
expect(schema.decodeSync(' hello ')).toBe('hello');
expect(schema.decodeSync('x')).toBe('x');
});
it('is a transform (effect) and keeps kind string', () => {
const schema = pipe(string(), trim());
expect(schema['~sium'].kind).toBe('string');
expect(schema['~sium'].effects).toContain('transform');
});
});
describe('toLowerCase() / toUpperCase()', () => {
it('normalizes case on decode', () => {
expect(pipe(string(), toLowerCase()).decodeSync('HeLLo')).toBe('hello');
expect(pipe(string(), toUpperCase()).decodeSync('HeLLo')).toBe('HELLO');
});
it('composes left-to-right (trim then lowercase)', () => {
const schema = pipe(string(), trim(), toLowerCase());
expect(schema.decodeSync(' Ada@Example.COM ')).toBe('ada@example.com');
});
});
describe('nullish()', () => {
const schema = nullish(number());
it('accepts undefined, null and valid values', () => {
expect(schema.decodeSync(undefined)).toBeUndefined();
expect(schema.decodeSync(null)).toBeNull();
expect(schema.decodeSync(42)).toBe(42);
});
it('rejects invalid non-nullish values', () => {
expect(schema.validateSync('nope' as never).ok).toBe(false);
});
it('reports both optional and nullable wrappers, kind preserved', () => {
expect(schema['~sium'].kind).toBe('number');
expect(schema['~sium'].wrappers).toEqual(['optional', 'nullable']);
});
it('encodes undefined / null directly', () => {
expect(schema.encode(undefined)).toBeUndefined();
expect(schema.encode(null)).toBeNull();
expect(schema.encode(7)).toBe(7);
});
});

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { boolean, number, string, tuple } from '../core';
describe('tuple()', () => {
const schema = tuple(string(), number(), boolean());
it('decodes a matching tuple positionally', () => {
expect(schema.decodeSync(['a', 1, true])).toEqual(['a', 1, true]);
});
it('validates each position and reports the index path', () => {
const result = schema.validateSync(['a', 'nope', true] as never);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues[0].path).toEqual([1]);
expect(result.issues[0].code).toBe('type');
}
});
it('rejects a wrong length with tuple_length + params', () => {
const result = schema.validateSync(['a', 1] as never);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues[0].code).toBe('tuple_length');
expect(result.issues[0].params).toEqual({ length: 3, actual: 2 });
}
});
it('rejects non-arrays with a type issue', () => {
const result = schema.validateSync({} as never);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.issues[0].code).toBe('type');
});
it('exposes tuple introspection', () => {
expect(schema['~sium'].kind).toBe('tuple');
expect(schema['~sium'].shape).toMatchObject({ kind: 'tuple' });
});
it('encodes element-by-element (identity for primitives)', () => {
expect(schema.encode(['a', 1, true])).toEqual(['a', 1, true]);
});
});

@ -1,6 +1,7 @@
import { SiumValidationError } from '../errors';
import { createSchema } from '../core/schema';
import { issue } from '../core/issue';
import { actualType } from '../core/internals';
import type { MetaAnnotations, Schema, SchemaKind } from '../core/types';
type DomainSchemaOptions<T> = {
@ -77,7 +78,7 @@ function createDomainTypeError(expected: string, input: unknown): SiumValidation
issue({
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: { expected, actual: String(input) }
params: { expected, actual: actualType(input) }
})
]);
}

@ -6,6 +6,7 @@ import { integer, max, min } from '../core';
import { createSchema } from '../core/schema';
import { SiumValidationError } from '../core';
import { issue } from '../core/issue';
import { actualType } from '../core/internals';
import { createDomainRangeSchema, createDomainValueSchema } from './_helpers';
function isDateValue(value: unknown): value is DateValue {
@ -35,10 +36,7 @@ function isOrderedDateRange(value: DateRange): boolean {
*
* Checks that the input is a dias `DateValue`-compatible object.
*/
export function dateValue(opts?: {
calendar?: 'gregorian' | 'buddhist' | string;
granularity?: 'day' | 'hour' | 'minute' | 'second';
}) {
export function dateValue() {
return createDomainValueSchema<DateValue>({
kind: 'date',
expected: 'DateValue',
@ -77,7 +75,7 @@ export function coerceDate() {
issue({
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: { expected: 'Date | DateValue', actual: String(input) }
params: { expected: 'Date | DateValue', actual: actualType(input) }
})
]);
},

@ -33,13 +33,9 @@ function isOrderedTimeRange(value: TimeRange): boolean {
/**
* Validates a composite time value (Time | CalendarDateTime | ZonedDateTime).
*
* Checks that the input is a dias `TimeValue`-compatible object and optionally
* enforces a minimum granularity via a refine step.
* Checks that the input is a dias `TimeValue`-compatible object.
*/
export function timeValue(opts?: {
granularity?: 'hour' | 'minute' | 'second';
hourCycle?: 12 | 24;
}) {
export function timeValue() {
return createDomainValueSchema<TimeValue>({
kind: 'time',
expected: 'TimeValue',

Loading…
Cancel
Save

Powered by TurnKey Linux.