@oh-my-pi/omptype 17.2.6 → 17.2.8

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.
@@ -2,24 +2,92 @@ import type { IR, PropIR } from "./ir";
2
2
 
3
3
  export interface JsonSchemaOptions {
4
4
  description?: string;
5
+ target?: string;
6
+ dialect?: string | null;
7
+ /**
8
+ * Which side of morphs and defaults to describe:
9
+ * - `'input'` — accepted payloads: morphs emit their input shape, defaulted
10
+ * properties are optional (with `default` annotations).
11
+ * - `'output'` — produced values: morphs emit their output shape, defaulted
12
+ * properties are required (always present after validation).
13
+ * Unset keeps the hybrid legacy behavior (morph output, defaults optional).
14
+ */
15
+ io?: "input" | "output";
16
+ fallback?: (context: { base: Record<string, unknown> }) => unknown;
5
17
  }
6
18
 
7
19
  type JsonSchema = Record<string, unknown>;
8
20
 
9
- /** Emit the draft-2020-12 JSON Schema represented by an IR tree. */
21
+ interface EmitCtx {
22
+ options?: JsonSchemaOptions;
23
+ /** Emitted alias definitions, attached as `$defs` on the root schema. */
24
+ defs: Map<string, JsonSchema>;
25
+ /** Alias resolver identity → assigned `$defs` name (cycle-safe). */
26
+ refs: Map<() => IR, string>;
27
+ }
28
+
29
+ /** Emit the requested JSON Schema dialect represented by an IR tree. */
10
30
  export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema {
11
- const schema = emit(ir);
31
+ const ctx: EmitCtx = { options, defs: new Map(), refs: new Map() };
32
+ let schema = emit(ir, ctx);
33
+ if (ctx.defs.size > 0) schema.$defs = Object.fromEntries(ctx.defs);
34
+ if (options?.target === "draft-07") schema = toDraft7(schema);
35
+ const dialect = options?.dialect === null ? undefined : (options?.dialect ?? dialectFor(options?.target));
36
+ if (dialect !== undefined) schema.$schema = dialect;
12
37
  if (options?.description !== undefined) schema.description = options.description;
13
38
  return schema;
14
39
  }
15
40
 
16
- function emit(ir: IR): JsonSchema {
41
+ function dialectFor(target: string | undefined): string | undefined {
42
+ if (target === "draft-2020-12") return "https://json-schema.org/draft/2020-12/schema";
43
+ if (target === "draft-07") return "http://json-schema.org/draft-07/schema#";
44
+ return target !== undefined && (target.startsWith("http://") || target.startsWith("https://")) ? target : undefined;
45
+ }
46
+
47
+ function fallback(schema: JsonSchema, ctx: EmitCtx): JsonSchema {
48
+ const replacement = ctx.options?.fallback?.({ base: schema });
49
+ if (replacement === true) return {};
50
+ if (replacement === false) return { not: {} };
51
+ return typeof replacement === "object" && replacement !== null ? (replacement as JsonSchema) : schema;
52
+ }
53
+
54
+ function toDraft7(schema: JsonSchema): JsonSchema {
55
+ const converted: JsonSchema = {};
56
+ for (const key in schema) {
57
+ const value = schema[key];
58
+ if (key === "$ref" && typeof value === "string") {
59
+ converted.$ref = value.replace("#/$defs/", "#/definitions/");
60
+ } else if (key === "$defs") {
61
+ converted.definitions = toDraft7(value as JsonSchema);
62
+ } else if (key === "prefixItems" && Array.isArray(value)) {
63
+ converted.items = value.map(item =>
64
+ typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
65
+ );
66
+ } else if (key === "items" && "prefixItems" in schema) {
67
+ converted.additionalItems =
68
+ typeof value === "object" && value !== null ? toDraft7(value as JsonSchema) : value;
69
+ } else if (Array.isArray(value)) {
70
+ converted[key] = value.map(item =>
71
+ typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
72
+ );
73
+ } else if (typeof value === "object" && value !== null) {
74
+ converted[key] = toDraft7(value as JsonSchema);
75
+ } else {
76
+ converted[key] = value;
77
+ }
78
+ }
79
+ return converted;
80
+ }
81
+
82
+ function emit(ir: IR, ctx: EmitCtx): JsonSchema {
17
83
  let schema: JsonSchema;
18
84
  switch (ir.k) {
19
85
  case "unknown":
20
- case "undefined":
21
86
  schema = {};
22
87
  break;
88
+ case "undefined":
89
+ schema = fallback({}, ctx);
90
+ break;
23
91
  case "null":
24
92
  schema = { type: "null" };
25
93
  break;
@@ -29,6 +97,12 @@ function emit(ir: IR): JsonSchema {
29
97
  case "bigint":
30
98
  schema = { type: "integer" };
31
99
  break;
100
+ case "symbol":
101
+ schema = fallback({}, ctx);
102
+ break;
103
+ case "never":
104
+ schema = { not: {} };
105
+ break;
32
106
  case "anyobject":
33
107
  schema = { type: "object" };
34
108
  break;
@@ -42,22 +116,81 @@ function emit(ir: IR): JsonSchema {
42
116
  schema = emitLiteral(ir.v);
43
117
  break;
44
118
  case "union":
45
- schema = emitUnion(ir.members);
119
+ schema = emitUnion(ir.members, ctx);
120
+ break;
121
+ case "intersection":
122
+ schema = { allOf: ir.members.map(member => emit(member, ctx)) };
46
123
  break;
47
124
  case "array":
48
- schema = { type: "array", items: emit(ir.el) };
125
+ schema = { type: "array", items: emit(ir.el, ctx) };
49
126
  if (ir.min !== undefined) schema.minItems = ir.min;
50
127
  if (ir.max !== undefined) schema.maxItems = ir.max;
51
128
  break;
129
+ case "tuple": {
130
+ const prefixItems = ir.prefix.map(item => {
131
+ const itemSchema = emit(item.val, ctx);
132
+ if (item.hasDefault) {
133
+ itemSchema.default = item.defFactory && typeof item.def === "function" ? item.def() : item.def;
134
+ }
135
+ return itemSchema;
136
+ });
137
+ const required = ir.prefix.reduce(
138
+ (count, item) => count + (item.opt || item.hasDefault ? 0 : 1),
139
+ ir.postfix.length,
140
+ );
141
+ schema = { type: "array", prefixItems, minItems: required };
142
+ if (ir.variadic === undefined) {
143
+ schema.items = false;
144
+ } else {
145
+ schema.items = emit(ir.variadic, ctx);
146
+ }
147
+ break;
148
+ }
52
149
  case "object":
53
- schema = emitObject(ir.props, ir.index, ir.extras);
150
+ schema = emitObject(ir.props, ir.index, ir.extras, ctx);
54
151
  break;
152
+ case "instance":
153
+ schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, ctx);
154
+ break;
155
+ case "refine":
156
+ schema = emit(ir.base, ctx);
157
+ if (ir.json !== undefined) Object.assign(schema, ir.json);
158
+ break;
159
+ case "morph":
160
+ schema = ctx.options?.io === "input" ? emit(ir.input, ctx) : fallback(emit(ir.out ?? ir.input, ctx), ctx);
161
+ break;
162
+ case "alias": {
163
+ const known = ctx.refs.get(ir.resolve);
164
+ if (known !== undefined) {
165
+ schema = { $ref: `#/$defs/${known}` };
166
+ break;
167
+ }
168
+ // Register before lowering so cyclic aliases resolve to the same $ref
169
+ // instead of recursing forever.
170
+ let name = ir.name.replace(/[^\w.-]/g, "_");
171
+ while ([...ctx.refs.values()].includes(name)) name = `${name}_`;
172
+ ctx.refs.set(ir.resolve, name);
173
+ ctx.defs.set(name, emit(ir.resolve(), ctx));
174
+ schema = { $ref: `#/$defs/${name}` };
175
+ break;
176
+ }
55
177
  case "sub":
56
- schema = emit(ir.schema.ir);
57
- if (ir.schema.description !== undefined) schema.description = ir.schema.description;
178
+ if (ctx.options?.io === "output" && ir.schema.opaqueOutput) {
179
+ schema = {};
180
+ } else if (ctx.options?.io === "output" && ir.schema.stepOut !== undefined) {
181
+ schema = emit(ir.schema.stepOut, ctx);
182
+ } else if (ctx.options?.io === "input") {
183
+ schema = emit(ir.schema.ir, ctx);
184
+ } else {
185
+ schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, ctx), ctx) : emit(ir.schema.ir, ctx);
186
+ }
187
+ if (ir.schema.ir.desc !== undefined) schema.description = ir.schema.ir.desc;
188
+ else if (ir.schema.description !== undefined && ir.descAuto !== true) {
189
+ schema.description = ir.schema.description;
190
+ }
58
191
  break;
59
192
  }
60
- if (ir.desc !== undefined) schema.description = ir.desc;
193
+ if (ir.desc !== undefined && ir.descAuto !== true) schema.description = ir.desc;
61
194
  return schema;
62
195
  }
63
196
 
@@ -73,10 +206,12 @@ function emitNumber(ir: Extract<IR, { k: "number" }>): JsonSchema {
73
206
  const schema: JsonSchema = { type: ir.int ? "integer" : "number" };
74
207
  if (ir.min !== undefined) schema[ir.xmin ? "exclusiveMinimum" : "minimum"] = ir.min;
75
208
  if (ir.max !== undefined) schema[ir.xmax ? "exclusiveMaximum" : "maximum"] = ir.max;
209
+ if (ir.divisor !== undefined) schema.multipleOf = ir.divisor;
76
210
  return schema;
77
211
  }
78
212
 
79
213
  function emitLiteral(value: unknown): JsonSchema {
214
+ if (value instanceof Date) return { type: "string", format: "date-time", const: value.toISOString() };
80
215
  if (isJsonValue(value)) return { const: value };
81
216
  switch (typeof value) {
82
217
  case "string":
@@ -94,10 +229,10 @@ function emitLiteral(value: unknown): JsonSchema {
94
229
  }
95
230
  }
96
231
 
97
- function emitUnion(members: IR[]): JsonSchema {
232
+ function emitUnion(members: IR[], ctx: EmitCtx): JsonSchema {
98
233
  const defined = members.filter(member => member.k !== "undefined");
99
234
  if (defined.length === 0) return {};
100
- if (defined.length === 1) return emit(defined[0]);
235
+ if (defined.length === 1) return emit(defined[0], ctx);
101
236
  if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) {
102
237
  const values = defined.map(member => (member as Extract<IR, { k: "lit" }>).v);
103
238
  const schema: JsonSchema = { enum: values };
@@ -105,7 +240,7 @@ function emitUnion(members: IR[]): JsonSchema {
105
240
  if (scalarType !== undefined) schema.type = scalarType;
106
241
  return schema;
107
242
  }
108
- return { anyOf: defined.map(emit) };
243
+ return { anyOf: defined.map(member => emit(member, ctx)) };
109
244
  }
110
245
 
111
246
  function homogeneousScalarType(values: unknown[]): string | undefined {
@@ -129,23 +264,31 @@ function jsonScalarType(value: unknown): string | undefined {
129
264
  }
130
265
  }
131
266
 
132
- function emitObject(props: PropIR[], index: IR | undefined, extras: "keep" | "reject" | "delete"): JsonSchema {
267
+ function emitObject(
268
+ props: PropIR[],
269
+ index: IR | undefined,
270
+ extras: "keep" | "reject" | "delete",
271
+ ctx: EmitCtx,
272
+ ): JsonSchema {
133
273
  const properties: Record<string, unknown> = {};
134
274
  const required: string[] = [];
275
+ const filled = (prop: PropIR): boolean => !prop.opt && (ctx.options?.io === "output" || !prop.hasDefault);
135
276
  // ArkType emits required properties first (each group in declaration
136
277
  // order); downstream wire consumers rely on that stable ordering.
137
- const ordered = [...props.filter(p => !p.opt && !p.hasDefault), ...props.filter(p => p.opt || p.hasDefault)];
278
+ const ordered = [...props.filter(filled), ...props.filter(prop => !filled(prop))];
138
279
  for (const prop of ordered) {
139
- const propertySchema = emit(prop.val);
280
+ if (typeof prop.key === "symbol") throw new TypeError("Cannot convert a symbol to a string");
281
+ const key = String(prop.key);
282
+ const propertySchema = emit(prop.val, ctx);
140
283
  if (prop.hasDefault) {
141
284
  propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def;
142
285
  }
143
- properties[prop.key] = propertySchema;
144
- if (!prop.opt && !prop.hasDefault) required.push(prop.key);
286
+ properties[key] = propertySchema;
287
+ if (filled(prop)) required.push(key);
145
288
  }
146
289
  const schema: JsonSchema = { type: "object", properties };
147
290
  if (required.length > 0) schema.required = required;
148
- if (index !== undefined) schema.additionalProperties = emit(index);
291
+ if (index !== undefined) schema.additionalProperties = emit(index, ctx);
149
292
  else if (extras === "reject") schema.additionalProperties = false;
150
293
  return schema;
151
294
  }
@@ -0,0 +1,380 @@
1
+ import type { IR, MorphContext } from "./ir";
2
+
3
+ const NUMERIC = /^(?:(?!^-0\.?0*$)(?:-?(?:(?:0|[1-9]\d*)(?:\.\d+)?)|\.\d+?))$/;
4
+ const INTEGER = /^[+-]?(?:0|[1-9]\d*)$/;
5
+ const EMAIL = /^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$/;
6
+ const SEMVER =
7
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*))?(?:\+([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?$/;
8
+ const UUID = /^[\da-f]{8}-[\da-f]{4}-[1-8][\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
9
+ const IPV4_SEGMENT = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])";
10
+ const IPV4_ADDRESS = `(?:${IPV4_SEGMENT}[.]){3}${IPV4_SEGMENT}`;
11
+ const IPV4 = new RegExp(`^${IPV4_ADDRESS}$`);
12
+ const IPV6_SEGMENT = "(?:[0-9a-fA-F]{1,4})";
13
+ const IPV6 = new RegExp(
14
+ "^(" +
15
+ `(?:${IPV6_SEGMENT}:){7}(?:${IPV6_SEGMENT}|:)|` +
16
+ `(?:${IPV6_SEGMENT}:){6}(?:${IPV4_ADDRESS}|:${IPV6_SEGMENT}|:)|` +
17
+ `(?:${IPV6_SEGMENT}:){5}(?::${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,2}|:)|` +
18
+ `(?:${IPV6_SEGMENT}:){4}(?:(:${IPV6_SEGMENT}){0,1}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,3}|:)|` +
19
+ `(?:${IPV6_SEGMENT}:){3}(?:(:${IPV6_SEGMENT}){0,2}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,4}|:)|` +
20
+ `(?:${IPV6_SEGMENT}:){2}(?:(:${IPV6_SEGMENT}){0,3}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,5}|:)|` +
21
+ `(?:${IPV6_SEGMENT}:){1}(?:(:${IPV6_SEGMENT}){0,4}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,6}|:)|` +
22
+ `(?::((?::${IPV6_SEGMENT}){0,5}:${IPV4_ADDRESS}|(?::${IPV6_SEGMENT}){1,7}|:))` +
23
+ ")(?:%[\\d.A-Za-z]{1,})?$",
24
+ );
25
+ const ISO_DATE =
26
+ /^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-3])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))(T((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([,.]\d+(?!:))?)?(\17[0-5]\d([,.]\d+)?)?([Zz]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
27
+
28
+ function pattern(regex: RegExp, expected: string, format?: string): IR {
29
+ return {
30
+ k: "refine",
31
+ base: { k: "string" },
32
+ pred: value => {
33
+ regex.lastIndex = 0;
34
+ return regex.test(value as string);
35
+ },
36
+ expected,
37
+ json: format === undefined ? { pattern: regex.source } : { pattern: regex.source, format },
38
+ };
39
+ }
40
+
41
+ function morph(input: IR, fn: (value: string, context: MorphContext) => unknown, out?: IR): IR {
42
+ return { k: "morph", input, fn: (value, context) => fn(value as string, context), out };
43
+ }
44
+
45
+ function parsableJson(): IR {
46
+ return {
47
+ k: "refine",
48
+ base: { k: "string" },
49
+ pred: value => {
50
+ try {
51
+ JSON.parse(value as string);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ },
57
+ expected: "a JSON string",
58
+ json: { contentMediaType: "application/json" },
59
+ };
60
+ }
61
+
62
+ function parsableDate(expected = "a parsable date"): IR {
63
+ return {
64
+ k: "refine",
65
+ base: { k: "string" },
66
+ pred: value => !Number.isNaN(new Date(value as string).valueOf()),
67
+ expected,
68
+ json: { format: "date-time" },
69
+ };
70
+ }
71
+
72
+ function normalize(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR {
73
+ return morph({ k: "string" }, value => value.normalize(form), patternForNormalized(form));
74
+ }
75
+
76
+ function patternForNormalized(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR {
77
+ return {
78
+ k: "refine",
79
+ base: { k: "string" },
80
+ pred: value => (value as string).normalize(form) === value,
81
+ expected: `${form}-normalized unicode`,
82
+ };
83
+ }
84
+
85
+ function uuidVersion(version: string): IR {
86
+ return pattern(
87
+ new RegExp(`^[\\da-f]{8}-[\\da-f]{4}-${version}[\\da-f]{3}-[89ab][\\da-f]{3}-[\\da-f]{12}$`, "i"),
88
+ `a UUIDv${version}`,
89
+ "uuid",
90
+ );
91
+ }
92
+
93
+ function isLuhnValid(input: string): boolean {
94
+ const value = input.replace(/[ -]+/g, "");
95
+ let sum = 0;
96
+ let double = false;
97
+ for (let index = value.length - 1; index >= 0; index--) {
98
+ let digit = Number(value[index]);
99
+ if (double) {
100
+ digit *= 2;
101
+ if (digit > 9) digit -= 9;
102
+ }
103
+ sum += digit;
104
+ double = !double;
105
+ }
106
+ return value.length > 0 && sum % 10 === 0;
107
+ }
108
+
109
+ function finiteNumber(expected = "a number"): IR {
110
+ return {
111
+ k: "refine",
112
+ base: { k: "unknown" },
113
+ pred: value => typeof value === "number" && !Number.isNaN(value),
114
+ expected,
115
+ };
116
+ }
117
+
118
+ function jsonObject(): IR {
119
+ let value: IR;
120
+ const resolveValue = (): IR => value;
121
+ const object: IR = {
122
+ k: "object",
123
+ props: [],
124
+ index: { k: "alias", name: "$jsonValue", resolve: resolveValue },
125
+ extras: "keep",
126
+ };
127
+ const array: IR = {
128
+ k: "array",
129
+ el: { k: "alias", name: "$jsonValue", resolve: resolveValue },
130
+ desc: "an object",
131
+ };
132
+ value = {
133
+ k: "union",
134
+ members: [
135
+ object,
136
+ array,
137
+ { k: "number" },
138
+ { k: "string" },
139
+ { k: "lit", v: false },
140
+ { k: "null" },
141
+ { k: "lit", v: true },
142
+ ],
143
+ };
144
+ return object;
145
+ }
146
+
147
+ function instance(ctor: new (...args: never[]) => object, name: string): IR {
148
+ return { k: "instance", ctor, expected: `${/^[AEIOU]/.test(name) ? "an" : "a"} ${name} instance` };
149
+ }
150
+
151
+ const keywordFactories: Record<string, () => IR> = {
152
+ string: () => ({ k: "string" }),
153
+ Key: () => ({ k: "union", members: [{ k: "string" }, { k: "symbol" }] }),
154
+ "unknown.any": () => ({ k: "unknown" }),
155
+ Array: () => ({ k: "array", el: { k: "unknown" } }),
156
+ Function: () => instance(Function as unknown as new (...args: never[]) => object, "Function"),
157
+ RegExp: () => instance(RegExp, "RegExp"),
158
+ File: () => instance(File, "File"),
159
+ Error: () => instance(Error, "Error"),
160
+ Set: () => instance(Set, "Set"),
161
+ Map: () => instance(Map, "Map"),
162
+ WeakSet: () => instance(WeakSet, "WeakSet"),
163
+ WeakMap: () => instance(WeakMap, "WeakMap"),
164
+ Promise: () => instance(Promise, "Promise"),
165
+ FormData: () => instance(FormData, "FormData"),
166
+ "FormData.parse": () => ({
167
+ k: "morph",
168
+ input: instance(FormData, "FormData"),
169
+ fn: value => {
170
+ const out: Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]> = {};
171
+ for (const [key, entry] of (value as FormData).entries()) {
172
+ const current = out[key];
173
+ out[key] = current === undefined ? entry : Array.isArray(current) ? [...current, entry] : [current, entry];
174
+ }
175
+ return out;
176
+ },
177
+ out: { k: "object", props: [], index: { k: "unknown" }, extras: "keep" },
178
+ }),
179
+ "object.json": jsonObject,
180
+ "object.json.stringify": () => ({
181
+ k: "morph",
182
+ input: jsonObject(),
183
+ fn: value => JSON.stringify(value),
184
+ out: { k: "string" },
185
+ }),
186
+ "number.epoch": () => ({
187
+ k: "refine",
188
+ base: {
189
+ k: "refine",
190
+ base: {
191
+ k: "refine",
192
+ base: finiteNumber("a number representing a Unix timestamp"),
193
+ pred: value => Number.isInteger(value),
194
+ expected: "an integer representing a Unix timestamp",
195
+ },
196
+ pred: value => (value as number) >= -8_640_000_000_000_000,
197
+ expected: "a Unix timestamp after -8640000000000000",
198
+ },
199
+ pred: value => (value as number) <= 8_640_000_000_000_000,
200
+ expected: "a Unix timestamp before 8640000000000000",
201
+ }),
202
+ "number.safe": () => ({
203
+ k: "refine",
204
+ base: {
205
+ k: "refine",
206
+ base: finiteNumber(),
207
+ pred: value => (value as number) >= Number.MIN_SAFE_INTEGER,
208
+ expected: `at least ${Number.MIN_SAFE_INTEGER}`,
209
+ },
210
+ pred: value => (value as number) <= Number.MAX_SAFE_INTEGER,
211
+ expected: `at most ${Number.MAX_SAFE_INTEGER}`,
212
+ }),
213
+ "number.NaN": () => ({
214
+ k: "refine",
215
+ base: { k: "unknown" },
216
+ pred: Number.isNaN,
217
+ expected: "NaN",
218
+ }),
219
+ "number.Infinity": () => ({ k: "lit", v: Number.POSITIVE_INFINITY }),
220
+ "number.NegativeInfinity": () => ({ k: "lit", v: Number.NEGATIVE_INFINITY }),
221
+ "string.alpha": () => pattern(/^[A-Za-z]*$/, "only letters"),
222
+ "string.alphanumeric": () => pattern(/^[\dA-Za-z]*$/, "only letters and digits 0-9"),
223
+ "string.hex": () => pattern(/^[\dA-Fa-f]+$/, "hex characters only"),
224
+ "string.base64": () => pattern(/^(?:[\d+/A-Za-z]{4})*(?:[\d+/A-Za-z]{2}==|[\d+/A-Za-z]{3}=)?$/, "base64-encoded"),
225
+ "string.base64.url": () =>
226
+ pattern(/^(?:[\w-]{4})*(?:[\w-]{2}(?:==|%3D%3D)?|[\w-]{3}(?:=|%3D)?)?$/, "base64url-encoded"),
227
+ "string.capitalize": () => morph({ k: "string" }, value => value.charAt(0).toUpperCase() + value.slice(1)),
228
+ "string.capitalize.preformatted": () => pattern(/^[A-Z].*$/, "capitalized"),
229
+ "string.creditCard": () => ({
230
+ k: "refine",
231
+ base: pattern(
232
+ /^(?:4\d{12}(?:\d{3,6})?|5[1-5]\d{14}|3[47]\d{13}|6(?:011|5\d\d)\d{12,15})$/,
233
+ "a credit card number",
234
+ ),
235
+ pred: value => isLuhnValid(value as string),
236
+ expected: "a credit card number",
237
+ }),
238
+ "string.date": () => parsableDate(),
239
+ "string.date.parse": () =>
240
+ morph(parsableDate(), value => new Date(value), { k: "instance", ctor: Date, expected: "a Date" }),
241
+ "string.date.iso": () => pattern(ISO_DATE, "an ISO 8601 date", "date-time"),
242
+ "string.date.iso.parse": () =>
243
+ morph(pattern(ISO_DATE, "an ISO 8601 date", "date-time"), value => new Date(value), {
244
+ k: "instance",
245
+ ctor: Date,
246
+ expected: "a Date",
247
+ }),
248
+ "string.date.epoch": () => pattern(INTEGER, "an integer string representing a Unix timestamp"),
249
+ "string.date.epoch.parse": () =>
250
+ morph(pattern(INTEGER, "an integer string representing a Unix timestamp"), value => new Date(Number(value)), {
251
+ k: "instance",
252
+ ctor: Date,
253
+ expected: "a Date",
254
+ }),
255
+ "string.digits": () => pattern(/^\d*$/, "only digits 0-9"),
256
+ "string.email": () => pattern(EMAIL, "an email address", "email"),
257
+ "string.integer": () => pattern(INTEGER, "a well-formed integer string"),
258
+ "string.integer.parse": () =>
259
+ morph(
260
+ pattern(INTEGER, "a well-formed integer string"),
261
+ (value, context) => {
262
+ const parsed = Number.parseInt(value, 10);
263
+ return Number.isSafeInteger(parsed) ? parsed : context.error("a safe integer string");
264
+ },
265
+ { k: "number", int: true },
266
+ ),
267
+ "string.ip": () => ({
268
+ k: "union",
269
+ members: [pattern(IPV4, "an IPv4 address", "ipv4"), pattern(IPV6, "an IPv6 address", "ipv6")],
270
+ }),
271
+ "string.ip.v4": () => pattern(IPV4, "an IPv4 address", "ipv4"),
272
+ "string.ip.v6": () => pattern(IPV6, "an IPv6 address", "ipv6"),
273
+ "string.json": () => parsableJson(),
274
+ "string.json.parse": () =>
275
+ morph(parsableJson(), (value, context) => {
276
+ try {
277
+ return JSON.parse(value);
278
+ } catch {
279
+ return context.error("a JSON string");
280
+ }
281
+ }),
282
+ "string.lower": () => morph({ k: "string" }, value => value.toLowerCase()),
283
+ "string.lower.preformatted": () => pattern(/^[a-z]*$/, "only lowercase letters"),
284
+ "string.normalize": () => normalize("NFC"),
285
+ "string.normalize.NFC": () => normalize("NFC"),
286
+ "string.normalize.NFC.preformatted": () => patternForNormalized("NFC"),
287
+ "string.normalize.NFD": () => normalize("NFD"),
288
+ "string.normalize.NFD.preformatted": () => patternForNormalized("NFD"),
289
+ "string.normalize.NFKC": () => normalize("NFKC"),
290
+ "string.normalize.NFKC.preformatted": () => patternForNormalized("NFKC"),
291
+ "string.normalize.NFKD": () => normalize("NFKD"),
292
+ "string.normalize.NFKD.preformatted": () => patternForNormalized("NFKD"),
293
+ "string.numeric": () => pattern(NUMERIC, "a well-formed numeric string"),
294
+ "string.numeric.parse": () =>
295
+ morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
296
+ "string.regex": () => ({
297
+ k: "refine",
298
+ base: { k: "string" },
299
+ pred: value => {
300
+ try {
301
+ new RegExp(value as string);
302
+ return true;
303
+ } catch {
304
+ return false;
305
+ }
306
+ },
307
+ expected: "a regex pattern",
308
+ json: { format: "regex" },
309
+ }),
310
+ "string.semver": () => pattern(SEMVER, "a semantic version"),
311
+ "string.trim": () => morph({ k: "string" }, value => value.trim()),
312
+ "string.trim.preformatted": () => pattern(/^\S.*\S$|^\S?$/, "trimmed"),
313
+ "string.upper": () => morph({ k: "string" }, value => value.toUpperCase()),
314
+ "string.upper.preformatted": () => pattern(/^[A-Z]*$/, "only uppercase letters"),
315
+ "string.url": () => ({ k: "string", url: true }),
316
+ "string.url.parse": () =>
317
+ morph({ k: "string", url: true }, value => new URL(value), { k: "instance", ctor: URL, expected: "a URL" }),
318
+ "string.uuid": () => pattern(UUID, "a UUID", "uuid"),
319
+ "string.uuid.v1": () => uuidVersion("1"),
320
+ "string.uuid.v2": () => uuidVersion("2"),
321
+ "string.uuid.v3": () => uuidVersion("3"),
322
+ "string.uuid.v4": () => uuidVersion("4"),
323
+ "string.uuid.v5": () => uuidVersion("5"),
324
+ "string.uuid.v6": () => uuidVersion("6"),
325
+ "string.uuid.v7": () => uuidVersion("7"),
326
+ "string.uuid.v8": () => uuidVersion("8"),
327
+ "parse.number": () =>
328
+ morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
329
+ "parse.integer": () =>
330
+ morph(pattern(INTEGER, "a well-formed integer string"), value => Number.parseInt(value, 10), {
331
+ k: "number",
332
+ int: true,
333
+ }),
334
+ "parse.json": () => keywordFactories["string.json.parse"](),
335
+ "parse.date": () => keywordFactories["string.date.parse"](),
336
+ "parse.url": () => keywordFactories["string.url.parse"](),
337
+ "parse.boolean": () =>
338
+ morph(pattern(/^(?:true|false)$/, "a boolean string"), value => value === "true", { k: "boolean" }),
339
+ "parse.bigint": () => morph(pattern(INTEGER, "an integer string"), value => BigInt(value), { k: "bigint" }),
340
+ };
341
+
342
+ /** Lower a built-in keyword into fresh validation IR. */
343
+ export function keywordIR(name: string): IR | undefined {
344
+ return keywordFactories[name]?.();
345
+ }
346
+
347
+ /** Lower a regular expression into a string refinement. */
348
+ export function patternIR(regex: RegExp): IR {
349
+ return pattern(regex, `a string matching ${regex}`);
350
+ }
351
+
352
+ /** Lower an ArkType-style template literal into a string pattern. */
353
+ export function templateIR(source: string): IR {
354
+ let patternSource = "^";
355
+ let index = 0;
356
+ const placeholder = /\$\{(string|number|bigint|boolean)\}/g;
357
+ for (let match = placeholder.exec(source); match; match = placeholder.exec(source)) {
358
+ patternSource += escapeRegex(source.slice(index, match.index));
359
+ switch (match[1]) {
360
+ case "number":
361
+ patternSource += "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
362
+ break;
363
+ case "bigint":
364
+ patternSource += "[+-]?\\d+";
365
+ break;
366
+ case "boolean":
367
+ patternSource += "(?:true|false)";
368
+ break;
369
+ default:
370
+ patternSource += ".*";
371
+ }
372
+ index = match.index + match[0].length;
373
+ }
374
+ patternSource += `${escapeRegex(source.slice(index))}$`;
375
+ return pattern(new RegExp(patternSource), `a string matching \`${source}\``);
376
+ }
377
+
378
+ function escapeRegex(value: string): string {
379
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
380
+ }