@oh-my-pi/omptype 17.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/type.ts ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The public `type()` parser and `Type` schema surface — an ArkType-compatible
3
+ * validator with a lazy JIT:
4
+ *
5
+ * - calls 1-2 run the tree-walking interpreter (near-zero setup cost, so
6
+ * schemas built per-request or validated once stay cheap)
7
+ * - the third call compiles a specialized validator via `new Function` and
8
+ * swaps it in; hot schemas validate in tens of nanoseconds
9
+ *
10
+ * A schema is a callable: `schema(data)` returns the (possibly morphed)
11
+ * output, or an `OmpErrors` on failure (`result instanceof type.errors`).
12
+ */
13
+ import { compile } from "./compile";
14
+ import { OmpErrors, OmpTypeError, TraversalError } from "./errors";
15
+ import type { InferDef, InferObjectLiteral, InferString } from "./infer";
16
+ import { walk } from "./interp";
17
+ import { type Def, embed, hasMorph, type IR, IR_BRAND, parseDef } from "./ir";
18
+ import { irToJsonSchema } from "./json-schema";
19
+
20
+ /** Context passed to `.narrow()` / `.pipe()` callbacks. */
21
+ export interface NarrowContext {
22
+ /** Record `must be <expectation>` and signal failure. */
23
+ mustBe(expectation: string): false;
24
+ /** Record a custom problem and signal failure. */
25
+ reject(problem: string): false;
26
+ }
27
+
28
+ /** Options accepted by `Type.toJsonSchema` (ArkType-compatible; emission is always draft 2020-12). */
29
+ export interface ToJsonSchemaOptions {
30
+ target?: string;
31
+ dialect?: string;
32
+ fallback?: (ctx: { base: Record<string, unknown> }) => unknown;
33
+ }
34
+
35
+ interface SchemaInference<out t> {
36
+ readonly [IR_BRAND]: true;
37
+ readonly infer: t;
38
+ }
39
+
40
+ /** A compiled schema: callable validator plus composition methods. */
41
+ export interface Type<out t = unknown> {
42
+ (data: unknown): t | OmpErrors;
43
+ readonly [IR_BRAND]: true;
44
+ /** Structural IR (base type; runtime steps live in `steps`). */
45
+ readonly ir: IR;
46
+ /** `.pipe()` / `.narrow()` steps applied after structural validation. */
47
+ readonly hasSteps: boolean;
48
+ readonly hasDefault: boolean;
49
+ readonly defaultValue?: unknown;
50
+ readonly description?: string;
51
+ /** Full validate+morph pipeline; identical to calling the schema. */
52
+ readonly run: (data: unknown) => unknown;
53
+
54
+ /** Inference-only output type (no runtime value). */
55
+ readonly infer: t;
56
+ /** Inference-only input type (no runtime value). */
57
+ readonly inferIn: t;
58
+
59
+ /** Structural + narrow check without running pipes. */
60
+ allows(data: unknown): data is t;
61
+ /** Validate and return output, throwing `TraversalError` on failure. */
62
+ assert(data: unknown): t;
63
+ /** JSON Schema (draft 2020-12) for this schema's structural base. */
64
+ toJsonSchema(options?: ToJsonSchemaOptions): Record<string, unknown>;
65
+ }
66
+
67
+ /** Schema returned by omptype builders, with precise object-literal composition inference. */
68
+ export interface FluentType<t = unknown> extends Type<t> {
69
+ describe(description: string): FluentType<t>;
70
+ default(value: t | (() => t)): FluentType<t>;
71
+ or<r>(def: SchemaInference<r>): FluentType<t | r>;
72
+ or<const def extends string>(def: def): FluentType<t | InferString<def>>;
73
+ or<const def extends Record<string, unknown>>(def: def): FluentType<t | InferObjectLiteral<def>>;
74
+ or(def: Def): FluentType<unknown>;
75
+ and<r>(def: SchemaInference<r>): FluentType<t & r>;
76
+ and<const def extends Record<string, unknown>>(def: def): FluentType<t & InferObjectLiteral<def>>;
77
+ and(def: Def): FluentType<unknown>;
78
+ array(): FluentType<t[]>;
79
+ atLeastLength(bound: number): FluentType<t>;
80
+ atMostLength(bound: number): FluentType<t>;
81
+ atLeast(bound: number): FluentType<t>;
82
+ atMost(bound: number): FluentType<t>;
83
+ pipe<r>(fn: (data: t, ctx: NarrowContext) => r): FluentType<Exclude<r, OmpErrors>>;
84
+ narrow<narrowed extends t>(fn: (data: t, ctx: NarrowContext) => data is narrowed): FluentType<narrowed>;
85
+ narrow(fn: (data: t, ctx: NarrowContext) => boolean): FluentType<t>;
86
+ }
87
+
88
+ /** Runtime constructor-like value used by ArkType-compatible `instanceof Type` checks. */
89
+ export const Type = Object.defineProperty(function Type(): void {}, Symbol.hasInstance, {
90
+ value: (value: unknown): boolean =>
91
+ (typeof value === "function" || (typeof value === "object" && value !== null)) && IR_BRAND in value,
92
+ });
93
+
94
+ interface Step {
95
+ kind: "pipe" | "narrow";
96
+ fn: (data: never, ctx: NarrowContext) => unknown;
97
+ }
98
+
99
+ interface TypeMeta {
100
+ description?: string;
101
+ defaultValue?: unknown;
102
+ hasDefault?: boolean;
103
+ }
104
+
105
+ class Ctx implements NarrowContext {
106
+ expectation: string | undefined;
107
+
108
+ mustBe(expectation: string): false {
109
+ this.expectation = expectation;
110
+ return false;
111
+ }
112
+
113
+ reject(problem: string): false {
114
+ this.expectation = problem;
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /** Calls before the JIT compiles a schema (first two run the interpreter). */
120
+ const JIT_THRESHOLD = 3;
121
+
122
+ function makeType(ir: IR, steps: Step[], meta: TypeMeta): FluentType {
123
+ let calls = 0;
124
+ let impl = (data: unknown): unknown => {
125
+ if (++calls >= JIT_THRESHOLD) {
126
+ impl = compile(ir);
127
+ return impl(data);
128
+ }
129
+ return walk(ir, data);
130
+ };
131
+
132
+ const runSteps = (data: unknown): unknown => {
133
+ let out = impl(data);
134
+ if (out instanceof OmpErrors) return out;
135
+ for (const step of steps) {
136
+ const ctx = new Ctx();
137
+ // steps are typed against the schema's output; validated data satisfies it
138
+ const fn = step.fn as (data: unknown, ctx: NarrowContext) => unknown;
139
+ if (step.kind === "narrow") {
140
+ if (!fn(out, ctx)) return OmpErrors.single([], ctx.expectation ?? "valid (narrow predicate failed)", out);
141
+ } else {
142
+ out = fn(out, ctx);
143
+ if (out instanceof OmpErrors) return out;
144
+ }
145
+ }
146
+ return out;
147
+ };
148
+
149
+ const callable =
150
+ steps.length === 0 ? (data: unknown): unknown => impl(data) : (data: unknown): unknown => runSteps(data);
151
+
152
+ const self = callable as FluentType & {
153
+ [IR_BRAND]: true;
154
+ ir: IR;
155
+ hasSteps: boolean;
156
+ hasDefault: boolean;
157
+ defaultValue?: unknown;
158
+ description?: string;
159
+ run: (data: unknown) => unknown;
160
+ };
161
+ self[IR_BRAND] = true;
162
+ self.ir = ir;
163
+ self.hasSteps = steps.length > 0;
164
+ self.hasDefault = meta.hasDefault === true;
165
+ self.defaultValue = meta.defaultValue;
166
+ self.description = meta.description;
167
+ self.run = callable;
168
+ // ArkType schema callables deliberately do not expose Function#bind. Generic
169
+ // tool wrappers use that distinction to bind executable methods while passing
170
+ // callable parameter schemas through unchanged; exposing bind here would turn
171
+ // the schema into a bare bound function and strip its schema surface.
172
+ Object.defineProperty(self, "bind", { value: undefined });
173
+
174
+ const methods: Omit<FluentType, keyof typeof self | "infer" | "inferIn" | typeof IR_BRAND> = {
175
+ describe: (description: string) => makeType({ ...ir, desc: description }, steps, { ...meta, description }),
176
+ default: (value: unknown) => makeType(ir, steps, { ...meta, defaultValue: value, hasDefault: true }),
177
+ or: (def: unknown) => {
178
+ const other = parseDef(def as Def);
179
+ const a = embed(self);
180
+ const members = [...(a.k === "union" ? a.members : [a]), ...(other.k === "union" ? other.members : [other])];
181
+ return makeType({ k: "union", members }, [], {});
182
+ },
183
+ and: (def: unknown) => makeType(intersect(embed(self), parseDef(def as Def)), [], {}),
184
+ array: () => makeType({ k: "array", el: embed(self) }, [], {}),
185
+ atLeastLength: (bound: number) => makeType(withLengthBound(ir, "min", bound), steps, meta),
186
+ atMostLength: (bound: number) => makeType(withLengthBound(ir, "max", bound), steps, meta),
187
+ atLeast: (bound: number) => makeType(withNumericBound(ir, "min", bound), steps, meta),
188
+ atMost: (bound: number) => makeType(withNumericBound(ir, "max", bound), steps, meta),
189
+ pipe: (fn: (data: never, ctx: NarrowContext) => unknown) => makeType(ir, [...steps, { kind: "pipe", fn }], meta),
190
+ narrow: (fn: (data: never, ctx: NarrowContext) => boolean) =>
191
+ makeType(ir, [...steps, { kind: "narrow", fn }], meta),
192
+ allows: (data: unknown): boolean => {
193
+ const out = impl(data);
194
+ if (out instanceof OmpErrors) return false;
195
+ for (const step of steps) {
196
+ if (step.kind !== "narrow") continue;
197
+ const fn = step.fn as (data: unknown, ctx: NarrowContext) => unknown;
198
+ if (!fn(out, new Ctx())) return false;
199
+ }
200
+ return true;
201
+ },
202
+ assert: (data: unknown): unknown => {
203
+ const out = callable(data);
204
+ if (out instanceof OmpErrors) throw new TraversalError(out);
205
+ return out;
206
+ },
207
+ toJsonSchema: (_options?: ToJsonSchemaOptions) =>
208
+ irToJsonSchema(ir, meta.description === undefined ? undefined : { description: meta.description }),
209
+ } as never;
210
+ Object.assign(self, methods);
211
+ return self;
212
+ }
213
+
214
+ /** Merge two IR nodes for `.and()`; supports the object/object case this repo uses. */
215
+ function intersect(a: IR, b: IR): IR {
216
+ if (a.k === "object" && b.k === "object") {
217
+ const props = [...a.props];
218
+ for (const bp of b.props) {
219
+ const i = props.findIndex(p => p.key === bp.key);
220
+ if (i < 0) {
221
+ props.push(bp);
222
+ } else {
223
+ const ap = props[i];
224
+ props[i] = { ...ap, opt: ap.opt && bp.opt, val: intersect(ap.val, bp.val) };
225
+ }
226
+ }
227
+ const extras =
228
+ a.extras === "reject" || b.extras === "reject"
229
+ ? "reject"
230
+ : a.extras === "delete" || b.extras === "delete"
231
+ ? "delete"
232
+ : "keep";
233
+ const index = a.index && b.index ? intersect(a.index, b.index) : (a.index ?? b.index);
234
+ return { k: "object", props, index, extras };
235
+ }
236
+ if (a.k === "string" && b.k === "string") {
237
+ return { k: "string", min: maxOf(a.min, b.min), max: minOf(a.max, b.max), url: a.url || b.url };
238
+ }
239
+ if (a.k === "number" && b.k === "number") {
240
+ return {
241
+ k: "number",
242
+ int: a.int || b.int,
243
+ min: maxOf(a.min, b.min),
244
+ max: minOf(a.max, b.max),
245
+ xmin: a.xmin || b.xmin,
246
+ xmax: a.xmax || b.xmax,
247
+ };
248
+ }
249
+ if (a.k === "unknown") return b;
250
+ if (b.k === "unknown") return a;
251
+ if (JSON.stringify(a) === JSON.stringify(b)) return a;
252
+ throw new OmpTypeError(`unsupported intersection of ${a.k} and ${b.k}`);
253
+ }
254
+
255
+ function maxOf(a: number | undefined, b: number | undefined): number | undefined {
256
+ if (a === undefined) return b;
257
+ if (b === undefined) return a;
258
+ return Math.max(a, b);
259
+ }
260
+
261
+ function minOf(a: number | undefined, b: number | undefined): number | undefined {
262
+ if (a === undefined) return b;
263
+ if (b === undefined) return a;
264
+ return Math.min(a, b);
265
+ }
266
+
267
+ function withLengthBound(ir: IR, side: "min" | "max", bound: number): IR {
268
+ if (ir.k === "array" || ir.k === "string") {
269
+ return side === "min" ? { ...ir, min: bound } : { ...ir, max: bound };
270
+ }
271
+ throw new OmpTypeError(`cannot apply length bound to ${ir.k}`);
272
+ }
273
+ function withNumericBound(ir: IR, side: "min" | "max", bound: number): IR {
274
+ if (ir.k === "number") {
275
+ return side === "min" ? { ...ir, min: bound, xmin: false } : { ...ir, max: bound, xmax: false };
276
+ }
277
+ throw new OmpTypeError(`cannot apply numeric bound to ${ir.k}`);
278
+ }
279
+
280
+ /**
281
+ * The `type()` builder: parses a definition into a callable schema.
282
+ *
283
+ * The `const def` generic drives static inference (`typeof schema.infer`);
284
+ * the runtime is definition-shape-agnostic, hence the cast.
285
+ */
286
+ export function type<const def>(def: def): FluentType<InferDef<def>> {
287
+ return makeType(parseDef(def as Def), [], {}) as unknown as FluentType<InferDef<def>>;
288
+ }
289
+
290
+ export namespace type {
291
+ /** Error aggregate returned by failed validations (`result instanceof type.errors`). */
292
+ export const errors = OmpErrors;
293
+ export type errors = OmpErrors;
294
+
295
+ /** Keyword statics for fluent building, e.g. `type.number.atLeast(5)`. */
296
+ export const string: FluentType<string> = makeType({ k: "string" }, [], {}) as unknown as FluentType<string>;
297
+ export const number: FluentType<number> = makeType({ k: "number" }, [], {}) as unknown as FluentType<number>;
298
+ export const boolean: FluentType<boolean> = makeType({ k: "boolean" }, [], {}) as unknown as FluentType<boolean>;
299
+ export const unknown: FluentType<unknown> = makeType({ k: "unknown" }, [], {});
300
+
301
+ /** Union of literal values from a runtime array (`type.enumerated(...list)`). */
302
+ export function enumerated<const values extends readonly unknown[]>(...values: values): FluentType<values[number]> {
303
+ const ir: IR =
304
+ values.length === 1
305
+ ? { k: "lit", v: values[0] }
306
+ : { k: "union", members: values.map(v => ({ k: "lit", v }) as IR) };
307
+ return makeType(ir, [], {}) as unknown as FluentType<values[number]>;
308
+ }
309
+
310
+ /** Untyped builder for runtime-assembled definitions (`type.raw({...})`). */
311
+ export function raw(def: unknown): BaseType {
312
+ return makeType(parseDef(def as Def), [], {});
313
+ }
314
+ }
315
+
316
+ export interface ScopeOptions {
317
+ jitless?: boolean;
318
+ }
319
+
320
+ /** ArkType-compatible scope wrapper; omptype's global builder is already lazy. */
321
+ export function scope(_aliases: Record<string, unknown>, _options?: ScopeOptions): { type: typeof type } {
322
+ return { type };
323
+ }
324
+
325
+ /** A schema whose output type is not statically known (`type.raw` results). */
326
+ export type BaseType = FluentType<unknown>;
327
+
328
+ /** `hasMorph` re-export for diagnostics/tooling. */
329
+ export { hasMorph };