@atscript/typescript 0.1.24 → 0.1.26

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/dist/utils.d.ts CHANGED
@@ -181,6 +181,7 @@ interface TAtscriptAnnotatedType<T extends TAtscriptTypeDef = TAtscriptTypeDef,
181
181
  validator(opts?: Partial<TValidatorOptions>): Validator<this, DataType>;
182
182
  metadata: TMetadataMap<AtscriptMetadata>;
183
183
  optional?: boolean;
184
+ id?: string;
184
185
  }
185
186
  /** An annotated type that is also a class constructor (i.e. a generated interface class). */
186
187
  type TAtscriptAnnotatedTypeConstructor = TAtscriptAnnotatedType & (new (...args: any[]) => any);
@@ -193,6 +194,11 @@ declare function isAnnotatedType(type: any): type is TAtscriptAnnotatedType;
193
194
  * Used by the handle's .annotate() method and by generated mutation statements.
194
195
  */
195
196
  declare function annotate<K extends keyof AtscriptMetadata>(metadata: TMetadataMap<AtscriptMetadata> | undefined, key: K, value: AtscriptMetadata[K] extends Array<infer E> ? E : AtscriptMetadata[K], asArray?: boolean): void;
197
+ /**
198
+ * Clones a property's type tree in-place so mutations don't leak to shared refs.
199
+ * Used by mutating annotate codegen when paths cross ref boundaries.
200
+ */
201
+ declare function cloneRefProp(parentType: TAtscriptTypeDef, propName: string): void;
196
202
  type TKind = '' | 'array' | 'object' | 'union' | 'intersection' | 'tuple';
197
203
  /**
198
204
  * Creates a builder handle for constructing a {@link TAtscriptAnnotatedType} at runtime.
@@ -242,6 +248,7 @@ interface TAnnotatedTypeHandle {
242
248
  name?: string;
243
249
  }, chain?: string[]): TAnnotatedTypeHandle;
244
250
  annotate(key: keyof AtscriptMetadata, value: any, asArray?: boolean): TAnnotatedTypeHandle;
251
+ id(value: string): TAnnotatedTypeHandle;
245
252
  }
246
253
  /**
247
254
  * Checks whether an annotated type is a phantom type.
@@ -299,6 +306,20 @@ declare function buildJsonSchema(type: TAtscriptAnnotatedType): TJsonSchema;
299
306
  * @returns An annotated type with full validator support.
300
307
  */
301
308
  declare function fromJsonSchema(schema: TJsonSchema): TAtscriptAnnotatedType;
309
+ /**
310
+ * Merges multiple annotated types into a combined schema map with shared `$defs`.
311
+ *
312
+ * Each type must have an `id`. The returned `schemas` object contains individual
313
+ * schemas keyed by type id, and `$defs` contains all shared type definitions
314
+ * deduplicated across schemas.
315
+ *
316
+ * @param types - Array of annotated types, each with an `id`.
317
+ * @returns An object with `schemas` (keyed by id) and shared `$defs`.
318
+ */
319
+ declare function mergeJsonSchemas(types: TAtscriptAnnotatedType[]): {
320
+ schemas: Record<string, TJsonSchema>;
321
+ $defs: Record<string, TJsonSchema>;
322
+ };
302
323
 
303
324
  /**
304
325
  * Type-safe dispatch over `TAtscriptAnnotatedType` by its `type.kind`.
@@ -417,6 +438,7 @@ interface TSerializedAnnotatedTypeInner {
417
438
  type: TSerializedTypeDef;
418
439
  metadata: Record<string, unknown>;
419
440
  optional?: boolean;
441
+ id?: string;
420
442
  }
421
443
  interface TSerializedTypeFinal {
422
444
  kind: '';
@@ -511,5 +533,5 @@ declare function serializeAnnotatedType(type: TAtscriptAnnotatedType, options?:
511
533
  */
512
534
  declare function deserializeAnnotatedType(data: TSerializedAnnotatedType): TAtscriptAnnotatedType;
513
535
 
514
- export { SERIALIZE_VERSION, Validator, ValidatorError, annotate, buildJsonSchema, createDataFromAnnotatedType, defineAnnotatedType, deserializeAnnotatedType, flattenAnnotatedType, forAnnotatedType, fromJsonSchema, isAnnotatedType, isAnnotatedTypeOfPrimitive, isPhantomType, serializeAnnotatedType, throwFeatureDisabled };
515
- export type { InferDataType, TAnnotatedTypeHandle, TAtscriptAnnotatedType, TAtscriptAnnotatedTypeConstructor, TAtscriptDataType, TAtscriptTypeArray, TAtscriptTypeComplex, TAtscriptTypeDef, TAtscriptTypeFinal, TAtscriptTypeObject, TCreateDataOptions, TFlattenOptions, TMetadataMap, TProcessAnnotationContext, TSerializeOptions, TSerializedAnnotatedType, TSerializedAnnotatedTypeInner, TSerializedTypeArray, TSerializedTypeComplex, TSerializedTypeDef, TSerializedTypeFinal, TSerializedTypeObject, TValidatorOptions, TValidatorPlugin, TValidatorPluginContext, TValueResolver };
536
+ export { SERIALIZE_VERSION, Validator, ValidatorError, annotate, buildJsonSchema, cloneRefProp, createDataFromAnnotatedType, defineAnnotatedType, deserializeAnnotatedType, flattenAnnotatedType, forAnnotatedType, fromJsonSchema, isAnnotatedType, isAnnotatedTypeOfPrimitive, isPhantomType, mergeJsonSchemas, serializeAnnotatedType, throwFeatureDisabled };
537
+ export type { InferDataType, TAnnotatedTypeHandle, TAtscriptAnnotatedType, TAtscriptAnnotatedTypeConstructor, TAtscriptDataType, TAtscriptTypeArray, TAtscriptTypeComplex, TAtscriptTypeDef, TAtscriptTypeFinal, TAtscriptTypeObject, TCreateDataOptions, TFlattenOptions, TJsonSchema, TMetadataMap, TProcessAnnotationContext, TSerializeOptions, TSerializedAnnotatedType, TSerializedAnnotatedTypeInner, TSerializedTypeArray, TSerializedTypeComplex, TSerializedTypeDef, TSerializedTypeFinal, TSerializedTypeObject, TValidatorOptions, TValidatorPlugin, TValidatorPluginContext, TValueResolver };
package/dist/utils.mjs CHANGED
@@ -427,6 +427,63 @@ else metadata.set(key, [a, value]);
427
427
  } else metadata.set(key, [value]);
428
428
  else metadata.set(key, value);
429
429
  }
430
+ function cloneRefProp(parentType, propName) {
431
+ if (parentType.kind !== "object") return;
432
+ const objType = parentType;
433
+ const existing = objType.props.get(propName);
434
+ if (!existing) return;
435
+ const clonedType = cloneTypeDef(existing.type);
436
+ objType.props.set(propName, {
437
+ __is_atscript_annotated_type: true,
438
+ type: clonedType,
439
+ metadata: new Map(existing.metadata),
440
+ id: existing.id,
441
+ optional: existing.optional,
442
+ validator(opts) {
443
+ return new Validator(this, opts);
444
+ }
445
+ });
446
+ }
447
+ function cloneTypeDef(type) {
448
+ if (type.kind === "object") {
449
+ const obj = type;
450
+ return {
451
+ kind: "object",
452
+ props: new Map(Array.from(obj.props.entries()).map(([k, v]) => [k, {
453
+ __is_atscript_annotated_type: true,
454
+ type: v.type,
455
+ metadata: new Map(v.metadata),
456
+ id: v.id,
457
+ optional: v.optional,
458
+ validator(opts) {
459
+ return new Validator(this, opts);
460
+ }
461
+ }])),
462
+ propsPatterns: [...obj.propsPatterns],
463
+ tags: new Set(obj.tags)
464
+ };
465
+ }
466
+ if (type.kind === "array") {
467
+ const arr = type;
468
+ return {
469
+ kind: "array",
470
+ of: arr.of,
471
+ tags: new Set(arr.tags)
472
+ };
473
+ }
474
+ if (type.kind === "union" || type.kind === "intersection" || type.kind === "tuple") {
475
+ const complex = type;
476
+ return {
477
+ kind: type.kind,
478
+ items: [...complex.items],
479
+ tags: new Set(complex.tags)
480
+ };
481
+ }
482
+ return {
483
+ ...type,
484
+ tags: new Set(type.tags)
485
+ };
486
+ }
430
487
  function defineAnnotatedType(_kind, base) {
431
488
  const kind = _kind || "";
432
489
  const type = base?.type || {};
@@ -518,6 +575,7 @@ else if (!newBase) throw new Error(`"${typeName}" is not annotated type`);
518
575
  __is_atscript_annotated_type: true,
519
576
  type: newBase.type,
520
577
  metadata,
578
+ id: newBase.id,
521
579
  validator(opts) {
522
580
  return new Validator(this, opts);
523
581
  }
@@ -528,6 +586,10 @@ else if (!newBase) throw new Error(`"${typeName}" is not annotated type`);
528
586
  annotate(key, value, asArray) {
529
587
  annotate(this.$metadata, key, value, asArray);
530
588
  return this;
589
+ },
590
+ id(value) {
591
+ this.$type.id = value;
592
+ return this;
531
593
  }
532
594
  };
533
595
  return handle;
@@ -551,40 +613,112 @@ function isAnnotatedTypeOfPrimitive(t) {
551
613
 
552
614
  //#endregion
553
615
  //#region packages/typescript/src/json-schema.ts
616
+ /**
617
+ * Detects a discriminator property across union items.
618
+ *
619
+ * Scans all items for object-typed members that share a common property
620
+ * with distinct const/literal values. If exactly one such property exists,
621
+ * it is returned as the discriminator.
622
+ */ function detectDiscriminator(items) {
623
+ if (items.length < 2) return null;
624
+ for (const item of items) if (item.type.kind !== "object") return null;
625
+ const firstObj = items[0].type;
626
+ const candidates = [];
627
+ for (const [propName, propType] of firstObj.props.entries()) if (propType.type.kind === "" && propType.type.value !== undefined) candidates.push(propName);
628
+ const validCandidates = [];
629
+ for (const candidate of candidates) {
630
+ const values = new Set();
631
+ const mapping = {};
632
+ let valid = true;
633
+ for (let i = 0; i < items.length; i++) {
634
+ const obj = items[i].type;
635
+ const prop = obj.props.get(candidate);
636
+ if (!prop || prop.type.kind !== "" || prop.type.value === undefined) {
637
+ valid = false;
638
+ break;
639
+ }
640
+ const val = prop.type.value;
641
+ if (values.has(val)) {
642
+ valid = false;
643
+ break;
644
+ }
645
+ values.add(val);
646
+ mapping[String(val)] = `#/oneOf/${i}`;
647
+ }
648
+ if (valid) validCandidates.push({
649
+ propertyName: candidate,
650
+ mapping
651
+ });
652
+ }
653
+ if (validCandidates.length === 1) return validCandidates[0];
654
+ return null;
655
+ }
554
656
  function buildJsonSchema(type) {
657
+ const defs = {};
658
+ let isRoot = true;
659
+ const buildObject = (d) => {
660
+ const properties = {};
661
+ const required = [];
662
+ for (const [key, val] of d.type.props.entries()) {
663
+ if (isPhantomType(val)) continue;
664
+ properties[key] = build$1(val);
665
+ if (!val.optional) required.push(key);
666
+ }
667
+ const schema$1 = {
668
+ type: "object",
669
+ properties
670
+ };
671
+ if (required.length > 0) schema$1.required = required;
672
+ return schema$1;
673
+ };
555
674
  const build$1 = (def) => {
675
+ if (def.id && def.type.kind === "object" && !isRoot) {
676
+ const name = def.id;
677
+ if (!defs[name]) {
678
+ defs[name] = {};
679
+ defs[name] = buildObject(def);
680
+ }
681
+ return { $ref: `#/$defs/${name}` };
682
+ }
683
+ isRoot = false;
556
684
  const meta = def.metadata;
557
685
  return forAnnotatedType(def, {
558
686
  phantom() {
559
687
  return {};
560
688
  },
561
689
  object(d) {
562
- const properties = {};
563
- const required = [];
564
- for (const [key, val] of d.type.props.entries()) {
565
- if (isPhantomType(val)) continue;
566
- properties[key] = build$1(val);
567
- if (!val.optional) required.push(key);
568
- }
569
- const schema = {
570
- type: "object",
571
- properties
572
- };
573
- if (required.length > 0) schema.required = required;
574
- return schema;
690
+ return buildObject(d);
575
691
  },
576
692
  array(d) {
577
- const schema = {
693
+ const schema$1 = {
578
694
  type: "array",
579
695
  items: build$1(d.type.of)
580
696
  };
581
697
  const minLength = meta.get("expect.minLength");
582
- if (minLength) schema.minItems = typeof minLength === "number" ? minLength : minLength.length;
698
+ if (minLength) schema$1.minItems = typeof minLength === "number" ? minLength : minLength.length;
583
699
  const maxLength = meta.get("expect.maxLength");
584
- if (maxLength) schema.maxItems = typeof maxLength === "number" ? maxLength : maxLength.length;
585
- return schema;
700
+ if (maxLength) schema$1.maxItems = typeof maxLength === "number" ? maxLength : maxLength.length;
701
+ return schema$1;
586
702
  },
587
703
  union(d) {
704
+ const disc = detectDiscriminator(d.type.items);
705
+ if (disc) {
706
+ const oneOf = d.type.items.map(build$1);
707
+ const mapping = {};
708
+ for (const [val, origPath] of Object.entries(disc.mapping)) {
709
+ const idx = Number.parseInt(origPath.split("/").pop());
710
+ const item = d.type.items[idx];
711
+ if (item.id && defs[item.id]) mapping[val] = `#/$defs/${item.id}`;
712
+ else mapping[val] = origPath;
713
+ }
714
+ return {
715
+ oneOf,
716
+ discriminator: {
717
+ propertyName: disc.propertyName,
718
+ mapping
719
+ }
720
+ };
721
+ }
588
722
  return { anyOf: d.type.items.map(build$1) };
589
723
  },
590
724
  intersection(d) {
@@ -598,38 +732,54 @@ function buildJsonSchema(type) {
598
732
  };
599
733
  },
600
734
  final(d) {
601
- const schema = {};
602
- if (d.type.value !== undefined) schema.const = d.type.value;
735
+ const schema$1 = {};
736
+ if (d.type.value !== undefined) schema$1.const = d.type.value;
603
737
  if (d.type.designType && d.type.designType !== "any") {
604
- schema.type = d.type.designType === "undefined" ? "null" : d.type.designType;
605
- if (schema.type === "number" && meta.get("expect.int")) schema.type = "integer";
738
+ schema$1.type = d.type.designType === "undefined" ? "null" : d.type.designType;
739
+ if (schema$1.type === "number" && meta.get("expect.int")) schema$1.type = "integer";
606
740
  }
607
- if (schema.type === "string") {
608
- if (meta.get("meta.required")) schema.minLength = 1;
741
+ if (schema$1.type === "string") {
742
+ if (meta.get("meta.required")) schema$1.minLength = 1;
609
743
  const minLength = meta.get("expect.minLength");
610
- if (minLength) schema.minLength = typeof minLength === "number" ? minLength : minLength.length;
744
+ if (minLength) schema$1.minLength = typeof minLength === "number" ? minLength : minLength.length;
611
745
  const maxLength = meta.get("expect.maxLength");
612
- if (maxLength) schema.maxLength = typeof maxLength === "number" ? maxLength : maxLength.length;
746
+ if (maxLength) schema$1.maxLength = typeof maxLength === "number" ? maxLength : maxLength.length;
613
747
  const patterns = meta.get("expect.pattern");
614
- if (patterns?.length) if (patterns.length === 1) schema.pattern = patterns[0].pattern;
615
- else schema.allOf = (schema.allOf || []).concat(patterns.map((p) => ({ pattern: p.pattern })));
748
+ if (patterns?.length) if (patterns.length === 1) schema$1.pattern = patterns[0].pattern;
749
+ else schema$1.allOf = (schema$1.allOf || []).concat(patterns.map((p) => ({ pattern: p.pattern })));
616
750
  }
617
- if (schema.type === "number" || schema.type === "integer") {
751
+ if (schema$1.type === "number" || schema$1.type === "integer") {
618
752
  const min = meta.get("expect.min");
619
- if (min) schema.minimum = typeof min === "number" ? min : min.minValue;
753
+ if (min) schema$1.minimum = typeof min === "number" ? min : min.minValue;
620
754
  const max = meta.get("expect.max");
621
- if (max) schema.maximum = typeof max === "number" ? max : max.maxValue;
755
+ if (max) schema$1.maximum = typeof max === "number" ? max : max.maxValue;
622
756
  }
623
- return schema;
757
+ return schema$1;
624
758
  }
625
759
  });
626
760
  };
627
- return build$1(type);
761
+ const schema = build$1(type);
762
+ if (Object.keys(defs).length > 0) return {
763
+ ...schema,
764
+ $defs: defs
765
+ };
766
+ return schema;
628
767
  }
629
768
  function fromJsonSchema(schema) {
769
+ const defsSource = schema.$defs || schema.definitions || {};
770
+ const resolved = new Map();
630
771
  const convert = (s) => {
631
772
  if (!s || Object.keys(s).length === 0) return defineAnnotatedType().designType("any").$type;
632
- if (s.$ref) throw new Error("$ref is not supported by fromJsonSchema. Dereference the schema first.");
773
+ if (s.$ref) {
774
+ const refName = s.$ref.replace(/^#\/(\$defs|definitions)\//, "");
775
+ if (resolved.has(refName)) return resolved.get(refName);
776
+ if (defsSource[refName]) {
777
+ const type = convert(defsSource[refName]);
778
+ resolved.set(refName, type);
779
+ return type;
780
+ }
781
+ throw new Error(`Unresolvable $ref: ${s.$ref}`);
782
+ }
633
783
  if ("const" in s) {
634
784
  const val = s.const;
635
785
  const dt = val === null ? "null" : typeof val;
@@ -717,6 +867,24 @@ function fromJsonSchema(schema) {
717
867
  };
718
868
  return convert(schema);
719
869
  }
870
+ function mergeJsonSchemas(types) {
871
+ const mergedDefs = {};
872
+ const schemas = {};
873
+ for (const type of types) {
874
+ const name = type.id;
875
+ if (!name) throw new Error("mergeJsonSchemas: all types must have an id");
876
+ const schema = buildJsonSchema(type);
877
+ if (schema.$defs) {
878
+ for (const [defName, defSchema] of Object.entries(schema.$defs)) if (!mergedDefs[defName]) mergedDefs[defName] = defSchema;
879
+ const { $defs: _,...rest } = schema;
880
+ schemas[name] = rest;
881
+ } else schemas[name] = schema;
882
+ }
883
+ return {
884
+ schemas,
885
+ $defs: mergedDefs
886
+ };
887
+ }
720
888
 
721
889
  //#endregion
722
890
  //#region packages/typescript/src/default-value.ts
@@ -910,6 +1078,7 @@ function serializeNode(def, path, options) {
910
1078
  metadata: serializeMetadata(def.metadata, path, def.type.kind, options)
911
1079
  };
912
1080
  if (def.optional) result.optional = true;
1081
+ if (def.id) result.id = def.id;
913
1082
  return result;
914
1083
  }
915
1084
  function serializeTypeDef(def, path, options) {
@@ -1013,6 +1182,7 @@ function deserializeNode(data) {
1013
1182
  }
1014
1183
  };
1015
1184
  if (data.optional) result.optional = true;
1185
+ if (data.id) result.id = data.id;
1016
1186
  return result;
1017
1187
  }
1018
1188
  function deserializeTypeDef(t) {
@@ -1058,4 +1228,4 @@ function deserializeTypeDef(t) {
1058
1228
  }
1059
1229
 
1060
1230
  //#endregion
1061
- export { SERIALIZE_VERSION, Validator, ValidatorError, annotate, buildJsonSchema, createDataFromAnnotatedType, defineAnnotatedType, deserializeAnnotatedType, flattenAnnotatedType, forAnnotatedType, fromJsonSchema, isAnnotatedType, isAnnotatedTypeOfPrimitive, isPhantomType, serializeAnnotatedType, throwFeatureDisabled };
1231
+ export { SERIALIZE_VERSION, Validator, ValidatorError, annotate, buildJsonSchema, cloneRefProp, createDataFromAnnotatedType, defineAnnotatedType, deserializeAnnotatedType, flattenAnnotatedType, forAnnotatedType, fromJsonSchema, isAnnotatedType, isAnnotatedTypeOfPrimitive, isPhantomType, mergeJsonSchemas, serializeAnnotatedType, throwFeatureDisabled };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/typescript",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Atscript: typescript-gen support.",
5
5
  "keywords": [
6
6
  "annotations",
@@ -64,7 +64,7 @@
64
64
  "vitest": "3.2.4"
65
65
  },
66
66
  "peerDependencies": {
67
- "@atscript/core": "^0.1.24"
67
+ "@atscript/core": "^0.1.26"
68
68
  },
69
69
  "build": [
70
70
  {},
@@ -31,7 +31,7 @@ import tsPlugin from '@atscript/typescript'
31
31
  import {
32
32
  defineAnnotatedType, isAnnotatedType, annotate,
33
33
  Validator, ValidatorError,
34
- buildJsonSchema, fromJsonSchema,
34
+ buildJsonSchema, fromJsonSchema, mergeJsonSchemas,
35
35
  serializeAnnotatedType, deserializeAnnotatedType,
36
36
  flattenAnnotatedType, createDataFromAnnotatedType,
37
37
  forAnnotatedType, throwFeatureDisabled,
@@ -70,6 +70,7 @@ Key points:
70
70
  The JS module creates actual classes with runtime type definitions and metadata:
71
71
 
72
72
  - Uses `defineAnnotatedType` (aliased as `$`) to build the type tree
73
+ - Each class gets a `static id` field with the stable type name (collision-safe via `__N` suffix)
73
74
  - Populates metadata maps with all annotation values
74
75
  - Wires up `validator()` and `toJsonSchema()` methods
75
76
  - When `exampleData: true`, adds `toExampleData()` that calls `createDataFromAnnotatedType(this, { mode: 'example' })` (aliased as `$e`)
@@ -12,6 +12,7 @@ interface TAtscriptAnnotatedType<T extends TAtscriptTypeDef = TAtscriptTypeDef>
12
12
  type: T // the type definition (shape)
13
13
  metadata: TMetadataMap<AtscriptMetadata> // annotation metadata
14
14
  optional?: boolean // whether this type is optional
15
+ id?: string // stable type name (set by codegen or .id() builder)
15
16
  validator(opts?): Validator // create a validator instance
16
17
  }
17
18
  ```
@@ -272,5 +273,6 @@ const labeledType = defineAnnotatedType().designType('string')
272
273
  | `.optional(flag?)` | Mark as optional |
273
274
  | `.annotate(key, value, asArray?)` | Set metadata annotation |
274
275
  | `.copyMetadata(from, ignore?)` | Copy metadata from another type |
275
- | `.refTo(type, chain?)` | Reference another annotated type's definition |
276
+ | `.id(name)` | Set a stable type name (used by `buildJsonSchema` for `$defs`/`$ref`) |
277
+ | `.refTo(type, chain?)` | Reference another annotated type's definition (carries `id`) |
276
278
  | `.$type` | Get the final `TAtscriptAnnotatedType` |
@@ -17,7 +17,7 @@ import {
17
17
  // Validation
18
18
  Validator, ValidatorError,
19
19
  // JSON Schema
20
- buildJsonSchema, fromJsonSchema,
20
+ buildJsonSchema, fromJsonSchema, mergeJsonSchemas,
21
21
  // Serialization
22
22
  serializeAnnotatedType, deserializeAnnotatedType, SERIALIZE_VERSION,
23
23
  // Flattening
@@ -55,7 +55,7 @@ All handlers except `phantom` are required. Each handler receives the type with
55
55
 
56
56
  ## `buildJsonSchema(type)` — Annotated Type → JSON Schema
57
57
 
58
- Converts an annotated type into a standard JSON Schema object, translating validation metadata:
58
+ Converts an annotated type into a standard JSON Schema object, translating validation metadata. Named object types (those with an `id`) are automatically extracted into `$defs` and referenced via `$ref`:
59
59
 
60
60
  ```ts
61
61
  import { buildJsonSchema } from '@atscript/typescript/utils'
@@ -73,6 +73,27 @@ const schema = buildJsonSchema(User)
73
73
  // }
74
74
  ```
75
75
 
76
+ ### `$defs` and `$ref`
77
+
78
+ Types compiled from `.as` files carry a stable `id` (the type name). When `buildJsonSchema` encounters named object types nested inside other types (unions, properties), it extracts them into `$defs` and references via `$ref`:
79
+
80
+ ```ts
81
+ import { CatOrDog } from './pets.as'
82
+ const schema = buildJsonSchema(CatOrDog)
83
+ // {
84
+ // $defs: { Cat: { type: 'object', ... }, Dog: { type: 'object', ... } },
85
+ // oneOf: [{ $ref: '#/$defs/Cat' }, { $ref: '#/$defs/Dog' }],
86
+ // discriminator: { propertyName: 'petType', mapping: { cat: '#/$defs/Cat', dog: '#/$defs/Dog' } }
87
+ // }
88
+ ```
89
+
90
+ Key behaviors:
91
+ - Only **named object types** (with `id`) are extracted to `$defs`. Primitives, unions, arrays stay inline.
92
+ - The **root type** is never extracted — it IS the schema.
93
+ - Same `id` referenced multiple times → one `$defs` entry, all occurrences become `$ref`.
94
+ - Types without `id` (inline/anonymous) produce inline schemas.
95
+ - For programmatic types, use `.id('Name')` on the builder to enable `$defs` extraction.
96
+
76
97
  ### Metadata → JSON Schema Mapping
77
98
 
78
99
  | Annotation | JSON Schema |
@@ -88,11 +109,15 @@ const schema = buildJsonSchema(User)
88
109
  | `@expect.pattern` (multiple) | `allOf: [{ pattern }, ...]` |
89
110
  | `@meta.required` on string | `minLength: 1` |
90
111
  | optional property | not in `required` array |
91
- | union | `anyOf` |
112
+ | union | `anyOf` (or `oneOf` + `discriminator` for discriminated unions) |
92
113
  | intersection | `allOf` |
93
114
  | tuple | `items` as array |
94
115
  | phantom | empty object `{}` (excluded) |
95
116
 
117
+ ### Discriminated Unions
118
+
119
+ When all union items are objects sharing exactly one property with distinct const/literal values, `buildJsonSchema` auto-detects it and emits `oneOf` with a `discriminator` object (including `propertyName` and `mapping`) instead of `anyOf`. When items have `id`, the mapping uses `$ref` paths into `$defs`. No annotations needed — detection is automatic.
120
+
96
121
  ## `fromJsonSchema(schema)` — JSON Schema → Annotated Type
97
122
 
98
123
  The inverse of `buildJsonSchema`. Creates a fully functional annotated type from a JSON Schema:
@@ -113,9 +138,26 @@ const type = fromJsonSchema({
113
138
  type.validator().validate({ name: 'Alice', age: 30 }) // passes
114
139
  ```
115
140
 
116
- Supports: `type`, `properties`, `required`, `items`, `anyOf`, `oneOf`, `allOf`, `enum`, `const`, `minLength`, `maxLength`, `minimum`, `maximum`, `pattern`, `minItems`, `maxItems`.
141
+ Supports: `type`, `properties`, `required`, `items`, `anyOf`, `oneOf`, `allOf`, `enum`, `const`, `minLength`, `maxLength`, `minimum`, `maximum`, `pattern`, `minItems`, `maxItems`, `$ref`/`$defs`.
142
+
143
+ `$ref` paths are automatically resolved from `$defs` or `definitions` in the schema. Unresolvable `$ref` throws an error.
144
+
145
+ ## `mergeJsonSchemas(types)` — Combine Schemas for OpenAPI
146
+
147
+ Combines multiple annotated types into a single schema map with shared `$defs` — useful for building OpenAPI `components/schemas`:
148
+
149
+ ```ts
150
+ import { mergeJsonSchemas } from '@atscript/typescript/utils'
151
+ import { CatOrDog } from './pets.as'
152
+ import { Order } from './orders.as'
153
+
154
+ const merged = mergeJsonSchemas([CatOrDog, Order])
155
+ // merged.schemas.CatOrDog — the CatOrDog schema (oneOf with $ref)
156
+ // merged.schemas.Order — the Order schema
157
+ // merged.$defs: { Cat, Dog, ... } — shared definitions, deduplicated
158
+ ```
117
159
 
118
- Does **not** support `$ref` dereference schemas first.
160
+ All types must have an `id` (all types compiled from `.as` files do). The function calls `buildJsonSchema` on each, hoists `$defs` into a shared pool, and returns individual schemas alongside merged definitions.
119
161
 
120
162
  ## `serializeAnnotatedType(type, options?)` — Serialize to JSON
121
163
 
@@ -167,7 +209,7 @@ type.validator().validate(someData)
167
209
  type.metadata.get('meta.label')
168
210
  ```
169
211
 
170
- Throws if the serialized version doesn't match `SERIALIZE_VERSION`.
212
+ Throws if the serialized version doesn't match `SERIALIZE_VERSION`. The `id` field is preserved through serialization/deserialization.
171
213
 
172
214
  ### `SERIALIZE_VERSION`
173
215