From 5ae52ac1a6331549a6e0b3d08d768ebdb980cd6b Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 26 Nov 2025 22:46:35 +0000 Subject: [PATCH 1/6] Add object helpers --- ambar-core/src/helpers/object.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ambar-core/src/helpers/object.ts diff --git a/ambar-core/src/helpers/object.ts b/ambar-core/src/helpers/object.ts new file mode 100644 index 0000000..756b75f --- /dev/null +++ b/ambar-core/src/helpers/object.ts @@ -0,0 +1,27 @@ +export { filterKeys, mapValues }; + +function filterKeys( + obj: O, + pred: (key: KK, value: O[KK]) => boolean +): Partial { + const result: Partial = {}; + const keys = Object.keys(obj) as K[]; + for (const key of keys) { + if (pred(key, obj[key])) { + result[key] = obj[key]; + } + } + return result; +} + +function mapValues( + obj: T, + fn: (key: K, value: T[K]) => R +): { [K in keyof T]: R } { + const out = {} as { [K in keyof T]: R }; + const keys = Object.keys(obj) as Array; + for (const k of keys) { + out[k] = fn(k, obj[k]); + } + return out; +} From 725e727fb0a3527e44fcbca46b9a495f6f59c4ad Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 26 Nov 2025 22:46:48 +0000 Subject: [PATCH 2/6] Implement discriminatedUnion schema --- ambar-core/src/json/schema.ts | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index 42e6aa6..a97d8c6 100644 --- a/ambar-core/src/json/schema.ts +++ b/ambar-core/src/json/schema.ts @@ -6,6 +6,7 @@ export { type Infer, type SchemaDef, type SchemaOptional, + type Variant, object, pair, triple, @@ -24,6 +25,8 @@ export { stringLiteral, stringEnum, oneOf, + discriminatedUnion, + variant, from, decode, encode, @@ -40,6 +43,7 @@ import { Encoder, EncoderDef } from './encoder'; import { Json } from './types'; import * as E from './encoder'; import { Maybe, Nullable } from '../maybe'; +import { filterKeys, mapValues } from '../helpers/object'; // Infer the type from a schema definition // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -181,3 +185,87 @@ const oneOf = (f: (v: V) => Schema, ss: Array>): Schema => D.oneOf(ss.map((s) => s.decoder)), E.oneOf((v) => f(v).encoder) ); + +const discriminatedUnion = []>( + vars: Variants +): Schema> => { + type Ty = Infer; + const d: D.Decoder = D.oneOf(vars.map((v) => v.schema.decoder)); + const e: E.Encoder = E.oneOf((v) => { + const found = vars.find((variant) => matches(variant.pattern, v)); + if (found == undefined) { + throw new Error(`Invalid distriminant in union type: '${v}'`); + } + + return found.schema.encoder; + }); + + return new Schema(d, e); +}; + +// Check whether a value matches a pattern. +const matches = (pattern: Json, val: unknown): boolean => { + // Handle null + if (pattern === null) { + return val === null; + } + + // Handle primitives (string, number, boolean) + if (typeof pattern !== 'object') { + return pattern === val; + } + + // Handle arrays + if (Array.isArray(pattern)) { + if (!Array.isArray(val)) return false; + if (pattern.length !== val.length) return false; + return pattern.every((p, i) => matches(p, val[i])); + } + + // Handle objects - check all properties in pattern exist in val and match + if (typeof val !== 'object' || val === null) { + return false; + } + + for (const key in pattern) { + if (!(key in val)) { + return false; + } + if ( + pattern[key] != undefined && + !matches(pattern[key], (val as any)[key]) + ) { + return false; + } + } + + return true; +}; + +// One option in a sum type. +// Includes the pattern that differentiates it from the other options in the type. +class Variant { + constructor( + readonly pattern: Json, + readonly schema: Schema + ) {} +} + +type VariantDef = { + [D in keyof A]: Schema | SchemaOptional | (A[D] & T); +}; + +const variant = ( + def: VariantDef +): Variant => { + const pattern = filterKeys(def, (_, v) => typeof v == 'string') as Json; + + const schemaDef: SchemaDef = mapValues(def, (_, value) => + // @ts-expect-error hard to prove the types, but this is correct. + typeof value == 'string' ? stringLiteral(value) : value + ); + + const schema: Schema = object(schemaDef); + + return new Variant(pattern, schema); +}; From 4105037219370fa48d47880be702aa0e1a119be7 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 26 Nov 2025 22:46:58 +0000 Subject: [PATCH 3/6] Add tests --- ambar-core/tests/suites/json.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ambar-core/tests/suites/json.ts b/ambar-core/tests/suites/json.ts index 621c33b..62fe773 100644 --- a/ambar-core/tests/suites/json.ts +++ b/ambar-core/tests/suites/json.ts @@ -106,6 +106,36 @@ const tests = group('json', [ expect.deep_equals(encoded, { one: 'wat' }); }), ]), + group('discriminatedUnion', [ + test('roundtrips first variant', () => + roundtrip( + s.discriminatedUnion([ + s.variant({ ty: 'one', a: s.string }), + s.variant({ ty: 'two', b: s.number }), + ]), + { ty: 'one', a: 'wat' } + )), + test('roundtrips second variant', () => + roundtrip( + s.discriminatedUnion([ + s.variant({ ty: 'one', a: s.string }), + s.variant({ ty: 'two', b: s.number }), + ]), + { ty: 'two', b: 2 } + )), + test('fails on unknown variant', () => + expect.throws( + () => + roundtrip( + s.discriminatedUnion([ + s.variant({ ty: 'one', a: s.string }), + s.variant({ ty: 'two', b: s.number }), + ]), + { c: 2 } as any + ), + (_) => true + )), + ]), ]), ]); From 69557dbda5fc1c44ec1a3ea77f3ecd11c7e3a182 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 26 Nov 2025 23:00:38 +0000 Subject: [PATCH 4/6] Simplify pattern matching --- ambar-core/src/helpers/object.ts | 18 ++++++++++++++++- ambar-core/src/json/schema.ts | 33 +++++++------------------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/ambar-core/src/helpers/object.ts b/ambar-core/src/helpers/object.ts index 756b75f..dc3c612 100644 --- a/ambar-core/src/helpers/object.ts +++ b/ambar-core/src/helpers/object.ts @@ -1,4 +1,4 @@ -export { filterKeys, mapValues }; +export { filterKeys, filterMap, mapValues }; function filterKeys( obj: O, @@ -14,6 +14,22 @@ function filterKeys( return result; } +// Filter keys and transform values at the same time +function filterMap( + obj: O, + fn: (key: K, value: O[K]) => R | undefined +): Partial<{ [K in keyof O]: R }> { + const result = {} as Partial<{ [K in keyof O]: R }>; + const keys = Object.keys(obj) as Array; + for (const key of keys) { + const mapped = fn(key, obj[key]); + if (mapped !== undefined) { + result[key] = mapped; + } + } + return result; +} + function mapValues( obj: T, fn: (key: K, value: T[K]) => R diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index a97d8c6..75a7ff0 100644 --- a/ambar-core/src/json/schema.ts +++ b/ambar-core/src/json/schema.ts @@ -43,7 +43,7 @@ import { Encoder, EncoderDef } from './encoder'; import { Json } from './types'; import * as E from './encoder'; import { Maybe, Nullable } from '../maybe'; -import { filterKeys, mapValues } from '../helpers/object'; +import { filterMap, mapValues } from '../helpers/object'; // Infer the type from a schema definition // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -204,25 +204,7 @@ const discriminatedUnion = []>( }; // Check whether a value matches a pattern. -const matches = (pattern: Json, val: unknown): boolean => { - // Handle null - if (pattern === null) { - return val === null; - } - - // Handle primitives (string, number, boolean) - if (typeof pattern !== 'object') { - return pattern === val; - } - - // Handle arrays - if (Array.isArray(pattern)) { - if (!Array.isArray(val)) return false; - if (pattern.length !== val.length) return false; - return pattern.every((p, i) => matches(p, val[i])); - } - - // Handle objects - check all properties in pattern exist in val and match +const matches = (pattern: Record, val: unknown): boolean => { if (typeof val !== 'object' || val === null) { return false; } @@ -231,10 +213,7 @@ const matches = (pattern: Json, val: unknown): boolean => { if (!(key in val)) { return false; } - if ( - pattern[key] != undefined && - !matches(pattern[key], (val as any)[key]) - ) { + if (pattern[key] != undefined && pattern[key] !== (val as any)[key]) { return false; } } @@ -246,7 +225,7 @@ const matches = (pattern: Json, val: unknown): boolean => { // Includes the pattern that differentiates it from the other options in the type. class Variant { constructor( - readonly pattern: Json, + readonly pattern: Record, readonly schema: Schema ) {} } @@ -258,7 +237,9 @@ type VariantDef = { const variant = ( def: VariantDef ): Variant => { - const pattern = filterKeys(def, (_, v) => typeof v == 'string') as Json; + const pattern = filterMap(def, (_, v): string | undefined => + typeof v == 'string' ? v : undefined + ) as Record; const schemaDef: SchemaDef = mapValues(def, (_, value) => // @ts-expect-error hard to prove the types, but this is correct. From b5a598381460f2c360b2a39d48253ef44749e537 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 26 Nov 2025 23:11:58 +0000 Subject: [PATCH 5/6] Guard against unspecified discriminant --- ambar-core/src/json/schema.ts | 8 +++++++- ambar-core/tests/suites/json.ts | 20 +++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index 75a7ff0..7478ca9 100644 --- a/ambar-core/src/json/schema.ts +++ b/ambar-core/src/json/schema.ts @@ -194,7 +194,7 @@ const discriminatedUnion = []>( const e: E.Encoder = E.oneOf((v) => { const found = vars.find((variant) => matches(variant.pattern, v)); if (found == undefined) { - throw new Error(`Invalid distriminant in union type: '${v}'`); + throw new Error(`Invalid discriminant in union type: '${v}'`); } return found.schema.encoder; @@ -241,6 +241,12 @@ const variant = ( typeof v == 'string' ? v : undefined ) as Record; + if (Object.keys(pattern).length == 0) { + throw new Error( + 'Invalid variant definition. No discriminant identified. Discriminant must be provided as a string' + ); + } + const schemaDef: SchemaDef = mapValues(def, (_, value) => // @ts-expect-error hard to prove the types, but this is correct. typeof value == 'string' ? stringLiteral(value) : value diff --git a/ambar-core/tests/suites/json.ts b/ambar-core/tests/suites/json.ts index 62fe773..67a0db2 100644 --- a/ambar-core/tests/suites/json.ts +++ b/ambar-core/tests/suites/json.ts @@ -6,6 +6,7 @@ import * as fc from 'fast-check'; import { Schema } from 'json/schema'; import { Json } from 'json/types'; import { Maybe, Nothing, Just, Nullable } from 'maybe'; +import * as assert from 'node:assert'; const tests = group('json', [ group('schema', [ @@ -133,7 +134,24 @@ const tests = group('json', [ ]), { c: 2 } as any ), - (_) => true + (err) => + assert.match(err.message, /Invalid discriminant in union type/) + )), + test('fails if discriminant is not specified', () => + expect.throws( + () => + roundtrip( + s.discriminatedUnion([ + s.variant({ ty: s.stringLiteral('one'), a: s.string }), + s.variant({ ty: 'two', b: s.number }), + ]), + { ty: 'two', b: 2 } + ), + (err) => + assert.match( + err.message, + /Discriminant must be provided as a string/ + ) )), ]), ]), From 4d45f4db6e64ac8f94eee8eb5f156a3b406bd31a Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Wed, 26 Nov 2025 23:15:02 +0000 Subject: [PATCH 6/6] Test payload mismatch --- ambar-core/tests/suites/json.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ambar-core/tests/suites/json.ts b/ambar-core/tests/suites/json.ts index 67a0db2..d9b8a7a 100644 --- a/ambar-core/tests/suites/json.ts +++ b/ambar-core/tests/suites/json.ts @@ -124,6 +124,18 @@ const tests = group('json', [ ]), { ty: 'two', b: 2 } )), + test('fails on payload mismatch', () => + expect.throws( + () => + roundtrip( + s.discriminatedUnion([ + s.variant({ ty: 'one', a: s.string }), + s.variant({ ty: 'two', b: s.number }), + ]), + { ty: 'one', a: 2 } as any + ), + (err) => assert.match(err.message, /Unable to decode/) + )), test('fails on unknown variant', () => expect.throws( () =>