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
parent
25131127bf
commit
37fe04693a
@ -1,14 +1,6 @@
|
|||||||
import { object, pipe, string, email, min, meta } from '$sium/core';
|
import { object, pipe, string, email, min, meta } from '$sium/core';
|
||||||
|
|
||||||
export const loginFormSchema = object({
|
export const loginFormSchema = object({
|
||||||
email: pipe(
|
email: pipe(string(), email(), meta({ label: 'Email' })),
|
||||||
string(),
|
password: pipe(string(), min(8), meta({ label: 'Password' }))
|
||||||
email(),
|
|
||||||
meta({ label: 'Email' })
|
|
||||||
),
|
|
||||||
password: pipe(
|
|
||||||
string(),
|
|
||||||
min(8),
|
|
||||||
meta({ label: 'Password' })
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,39 +1,11 @@
|
|||||||
import {
|
import { object, boolean, defaulted, enumOf, pipe, meta } from '$sium/core';
|
||||||
object,
|
|
||||||
boolean,
|
|
||||||
defaulted,
|
|
||||||
enumOf,
|
|
||||||
pipe,
|
|
||||||
meta
|
|
||||||
} from '$sium/core';
|
|
||||||
|
|
||||||
export const settingsFormSchema = object({
|
export const settingsFormSchema = object({
|
||||||
theme: pipe(
|
theme: pipe(enumOf(['light', 'dark', 'auto']), meta({ label: 'Theme' })),
|
||||||
enumOf(['light', 'dark', 'auto']),
|
locale: defaulted(pipe(enumOf(['es', 'en']), meta({ label: 'Locale' })), () => 'es'),
|
||||||
meta({ label: 'Theme' })
|
|
||||||
),
|
|
||||||
locale: defaulted(
|
|
||||||
pipe(
|
|
||||||
enumOf(['es', 'en']),
|
|
||||||
meta({ label: 'Locale' })
|
|
||||||
),
|
|
||||||
() => 'es'
|
|
||||||
),
|
|
||||||
notifications: object({
|
notifications: object({
|
||||||
email: pipe(
|
email: pipe(boolean(), meta({ label: 'Email notifications' })),
|
||||||
boolean(),
|
push: pipe(boolean(), meta({ label: 'Push notifications' })),
|
||||||
meta({ label: 'Email notifications' })
|
sms: defaulted(pipe(boolean(), meta({ label: 'SMS notifications' })), () => false)
|
||||||
),
|
|
||||||
push: pipe(
|
|
||||||
boolean(),
|
|
||||||
meta({ label: 'Push notifications' })
|
|
||||||
),
|
|
||||||
sms: defaulted(
|
|
||||||
pipe(
|
|
||||||
boolean(),
|
|
||||||
meta({ label: 'SMS notifications' })
|
|
||||||
),
|
|
||||||
() => false
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
export { array } from './array-combinator';
|
export { array } from './array-combinator';
|
||||||
export { object } from './object-combinator';
|
export { object } from './object-combinator';
|
||||||
export { discriminated, union } from './union-combinators';
|
export { discriminated, union } from './union-combinators';
|
||||||
|
export { tuple } from './tuple-combinator';
|
||||||
|
export { record } from './record-combinator';
|
||||||
|
|||||||
@ -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
|
||||||
|
>;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,2 +1 @@
|
|||||||
export { isPromiseLike, pathKeys } from '$libs/standard-schema';
|
|
||||||
export type { StandardSchemaV1 } from '$libs/standard-schema';
|
export type { StandardSchemaV1 } from '$libs/standard-schema';
|
||||||
|
|||||||
@ -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]> };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in new issue