diff --git a/ambar-core/src/helpers/object.ts b/ambar-core/src/helpers/object.ts new file mode 100644 index 0000000..dc3c612 --- /dev/null +++ b/ambar-core/src/helpers/object.ts @@ -0,0 +1,43 @@ +export { filterKeys, filterMap, 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; +} + +// 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 +): { [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; +} diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index 42e6aa6..7478ca9 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 { filterMap, mapValues } from '../helpers/object'; // Infer the type from a schema definition // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -181,3 +185,74 @@ 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 discriminant in union type: '${v}'`); + } + + return found.schema.encoder; + }); + + return new Schema(d, e); +}; + +// Check whether a value matches a pattern. +const matches = (pattern: Record, val: unknown): boolean => { + if (typeof val !== 'object' || val === null) { + return false; + } + + for (const key in pattern) { + if (!(key in val)) { + return false; + } + if (pattern[key] != undefined && 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: Record, + readonly schema: Schema + ) {} +} + +type VariantDef = { + [D in keyof A]: Schema | SchemaOptional | (A[D] & T); +}; + +const variant = ( + def: VariantDef +): Variant => { + const pattern = filterMap(def, (_, v): string | undefined => + 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 + ); + + const schema: Schema = object(schemaDef); + + return new Variant(pattern, schema); +}; diff --git a/ambar-core/tests/suites/json.ts b/ambar-core/tests/suites/json.ts index 621c33b..d9b8a7a 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', [ @@ -106,6 +107,65 @@ 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 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( + () => + roundtrip( + s.discriminatedUnion([ + s.variant({ ty: 'one', a: s.string }), + s.variant({ ty: 'two', b: s.number }), + ]), + { c: 2 } as any + ), + (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/ + ) + )), + ]), ]), ]);