From 70480accd0105de9ba63463dc486e8b69c290404 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Thu, 20 Nov 2025 23:37:44 +0000 Subject: [PATCH 1/2] Simplify optional encoding --- ambar-core/src/json/decoder.ts | 63 +++++++++---------------- ambar-core/src/json/encoder.ts | 84 +++++++++++++--------------------- ambar-core/src/json/schema.ts | 31 ++++--------- 3 files changed, 62 insertions(+), 116 deletions(-) diff --git a/ambar-core/src/json/decoder.ts b/ambar-core/src/json/decoder.ts index 2336f40..316b1b6 100644 --- a/ambar-core/src/json/decoder.ts +++ b/ambar-core/src/json/decoder.ts @@ -27,10 +27,9 @@ export { type FromJSON, type Infer, Decoder, + type DecoderOptional, type DecoderDef, type DecodeResult, - type DecoderOptionalNullable, - type DecoderOptionalMaybe, decode, object, objectMap, @@ -62,6 +61,7 @@ export { import { Result, Success, Failure, traverse } from '../result'; import { Maybe, Just, Nothing, Nullable } from '../maybe'; +import * as e from './encoder.ts'; import { List } from '../list'; import { Json } from './types'; @@ -162,10 +162,7 @@ const array = (decodeValue: Decoder): Decoder> => }); type DecoderDef = { - [P in keyof A]: - | Decoder - | DecoderOptionalNullable - | DecoderOptionalMaybe; + [P in keyof A]: Decoder | DecoderOptional; }; // Ignores extra properties. @@ -180,23 +177,13 @@ const object = (decoders: DecoderDef): Decoder => for (const field in decoders) { let decoder = decoders[field]; - if (decoder instanceof DecoderOptionalNullable) { - if (obj[field] === undefined) { - // @ts-expect-error: we know this must be a nullable value. - result[field] = null; - continue; - } - decoder = decoder.present; - } else if (decoder instanceof DecoderOptionalMaybe) { - if (obj[field] === undefined) { - // @ts-expect-error: we know this must be a Maybe value. - result[field] = Nothing(); - continue; - } - decoder = decoder.present; - } + const decoded = + decoder instanceof DecoderOptional + ? obj[field] === undefined + ? decoder.decoder.run({ nothing: {} }) + : decoder.decoder.run({ just: obj[field] }) + : decoder.run(obj[field]); - const decoded = decoder.run(obj[field]); switch (true) { case decoded instanceof Success: result[field] = decoded.value; @@ -337,34 +324,26 @@ const optional = (decoder: Decoder): Decoder> => undefinedP.map((_) => Nothing()) as Decoder>, ]); -// Decoder for a field that may not be present. -// If it is absent it will be decoded as 'null'. -class DecoderOptionalNullable { - private constructor(readonly present: Decoder) {} - static from( - d: Decoder> - ): DecoderOptionalNullable> { - return new DecoderOptionalNullable(d); - } -} - const optionalNullable = ( decoder: Decoder> -): DecoderOptionalNullable> => - DecoderOptionalNullable.from(decoder); +): DecoderOptional> => + optionalMaybe(decoder).map((v) => v.asNullable()); // Decoder for a field that may not be present. // If it is absent it will be decoded as 'Nothing()'. -class DecoderOptionalMaybe { - private constructor(readonly present: Decoder) {} - static from(d: Decoder): DecoderOptionalMaybe> { - return new DecoderOptionalMaybe(d.map(Just)); +class DecoderOptional { + private constructor(readonly decoder: Decoder) {} + static from(d: Decoder): DecoderOptional> { + return new DecoderOptional(maybe(d)); + } + + map(f: (v: A) => W): DecoderOptional { + return new DecoderOptional(this.decoder.map(f)); } } -const optionalMaybe = ( - decoder: Decoder -): DecoderOptionalMaybe> => DecoderOptionalMaybe.from(decoder); +const optionalMaybe = (decoder: Decoder): DecoderOptional> => + DecoderOptional.from(decoder); // Define a recursive decoder function rec(f: (p: Decoder) => Decoder): Decoder { diff --git a/ambar-core/src/json/encoder.ts b/ambar-core/src/json/encoder.ts index 1677f97..79c3e97 100644 --- a/ambar-core/src/json/encoder.ts +++ b/ambar-core/src/json/encoder.ts @@ -6,8 +6,7 @@ export { type Infer, Encoder, type EncoderDef, - type EncoderOptionalNullable, - type EncoderOptionalMaybe, + type EncoderOptional, json, boolean, number, @@ -45,10 +44,7 @@ class Encoder { } type EncoderDef = { - [P in keyof A]: - | Encoder - | EncoderOptionalNullable - | EncoderOptionalMaybe; + [P in keyof A]: Encoder | EncoderOptional; }; const toAny = (): Encoder => new Encoder((v) => v); @@ -74,21 +70,21 @@ const object = (encoders: EncoderDef): Encoder => for (const field in encoders) { const encoder = encoders[field]; switch (true) { - case encoder instanceof EncoderOptionalNullable: - if (input[field] !== null && input[field] !== undefined) { - const encoded = encoder.present.run(input[field]); - result[field] = encoded; - } - break; - case encoder instanceof EncoderOptionalMaybe: + case encoder instanceof EncoderOptional: { + const encoded = encoder.encoder.run(input[field]); if ( - !(input[field] instanceof Nothing) && - input[field] !== undefined + typeof encoded != 'object' || + encoded === null || + !('nothing' in encoded || 'just' in encoded) ) { - const encoded = encoder.present.run(input[field]); - result[field] = encoded; + throw new Error(`Invalid output of EncoderOptional: ${encoded}`); + } + + if ('just' in encoded) { + result[field] = encoded['just']; } break; + } case encoder instanceof Encoder: const encoded = encoder.run(input[field]); result[field] = encoded; @@ -127,48 +123,32 @@ const maybe = (encoder: Encoder): Encoder> => const nullable = (encoder: Encoder): Encoder> => new Encoder((input) => (input === null ? null : encoder.run(input))); -// An encoder for object keys that omits the field if the value is null. -class EncoderOptionalNullable { - private constructor(readonly present: Encoder) {} +// An encoder for object keys that omits the field if the value is Nothing. +class EncoderOptional { + private constructor(readonly encoder: Encoder) {} - static from( - e: Encoder> - ): EncoderOptionalNullable { - const encoder = new Encoder((input) => { - if (input == null) { - throw new Error('EncoderOptionalNullable called with null'); - } - return e.run(input); - }); + static from(e: Encoder>): EncoderOptional> { + return new EncoderOptional(e); + } - return new EncoderOptionalNullable(encoder); + rmap(f: (v: B) => A): EncoderOptional { + return new EncoderOptional(this.encoder.rmap(f)); } } +const optionalMaybe = (encoder: Encoder): EncoderOptional> => + EncoderOptional.from(maybe(encoder)); + const optionalNullable = ( encoder: Encoder> -): EncoderOptionalNullable> => - EncoderOptionalNullable.from(encoder); - -// An encoder for object keys that omits the field if the value is Nothing. -class EncoderOptionalMaybe { - private constructor(readonly present: Encoder) {} - - static from(e: Encoder): EncoderOptionalMaybe> { - const encoder = new Encoder>((input) => { - if (input instanceof Nothing) { - throw new Error('EncoderOptionalMaybe called with Nothing()'); - } - return e.run(input.value); - }); - - return new EncoderOptionalMaybe(encoder); - } -} - -const optionalMaybe = ( - encoder: Encoder -): EncoderOptionalMaybe> => EncoderOptionalMaybe.from(encoder); +): EncoderOptional> => + optionalMaybe(encoder).rmap((v) => + v === null + ? Nothing() + : v === undefined + ? Nothing() + : Just>(v) + ); // Encode a field that may not be there as a maybe. const optional = ( diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index d9addba..d5dc58f 100644 --- a/ambar-core/src/json/schema.ts +++ b/ambar-core/src/json/schema.ts @@ -5,6 +5,7 @@ export { Schema, type Infer, type SchemaDef, + type SchemaOptional, object, pair, triple, @@ -76,10 +77,7 @@ function from(decoder: Decoder, encoder: Encoder): Schema { } type SchemaDef = { - [D in keyof A]: - | Schema - | SchemaOptionalNullable - | SchemaOptionalMaybe; + [D in keyof A]: Schema | SchemaOptional; }; const json: Schema = new Schema(D.json, E.json); @@ -100,34 +98,23 @@ const both = (left: Schema, right: Schema): Schema<[T, U]> => // If it is absent: // - it will be decoded as 'null'. // - the encoded object will not contain the relevant key -class SchemaOptionalNullable { +class SchemaOptional { constructor( - readonly decoder: D.DecoderOptionalNullable, - readonly encoder: E.EncoderOptionalNullable + readonly decoder: D.DecoderOptional, + readonly encoder: E.EncoderOptional ) {} } const optionalNullable = ( schema: Schema> -): SchemaOptionalNullable> => - new SchemaOptionalNullable( +): SchemaOptional> => + new SchemaOptional( D.optionalNullable(schema.decoder), E.optionalNullable(schema.encoder) ); -// Schema for an object field that may not be present. -// If it is absent: -// - it will be decoded as 'Nothing()'. -// - the encoded object will not contain the relevant key -class SchemaOptionalMaybe { - constructor( - readonly decoder: D.DecoderOptionalMaybe, - readonly encoder: E.EncoderOptionalMaybe - ) {} -} - -const optionalMaybe = (schema: Schema): SchemaOptionalMaybe> => - new SchemaOptionalMaybe( +const optionalMaybe = (schema: Schema): SchemaOptional> => + new SchemaOptional( D.optionalMaybe(schema.decoder), E.optionalMaybe(schema.encoder) ); From 1eadfd013f8eba9e3d4c374d039ee0cc7b13ddd9 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Fri, 21 Nov 2025 00:17:57 +0000 Subject: [PATCH 2/2] Make 'optional' truly optional --- ambar-core/src/json/decoder.ts | 25 ++++++++++++------------- ambar-core/src/json/encoder.ts | 11 ++++------- ambar-core/src/json/schema.ts | 13 ++++--------- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/ambar-core/src/json/decoder.ts b/ambar-core/src/json/decoder.ts index 316b1b6..a5f9ad1 100644 --- a/ambar-core/src/json/decoder.ts +++ b/ambar-core/src/json/decoder.ts @@ -52,9 +52,9 @@ export { always, fail, failure, - optional, succeed, both, + optional, optionalNullable, optionalMaybe, }; @@ -317,18 +317,6 @@ const stringLiteral = (str: T): Decoder => v === str ? Success(v as T) : failure(`expected '${str}' but found '${v}'`) ); -// An object field that may be absent. -const optional = (decoder: Decoder): Decoder> => - oneOf([ - decoder.map(Just), - undefinedP.map((_) => Nothing()) as Decoder>, - ]); - -const optionalNullable = ( - decoder: Decoder> -): DecoderOptional> => - optionalMaybe(decoder).map((v) => v.asNullable()); - // Decoder for a field that may not be present. // If it is absent it will be decoded as 'Nothing()'. class DecoderOptional { @@ -345,6 +333,17 @@ class DecoderOptional { const optionalMaybe = (decoder: Decoder): DecoderOptional> => DecoderOptional.from(decoder); +const optionalNullable = ( + decoder: Decoder> +): DecoderOptional> => + optionalMaybe(decoder).map((v) => v.asNullable()); + +// An object field that may be absent. +const optional = (decoder: Decoder): DecoderOptional => + optionalMaybe(decoder).map((v) => + v instanceof Nothing ? undefined : v.value + ); + // Define a recursive decoder function rec(f: (p: Decoder) => Decoder): Decoder { const base: Decoder = fail( diff --git a/ambar-core/src/json/encoder.ts b/ambar-core/src/json/encoder.ts index 79c3e97..ed25570 100644 --- a/ambar-core/src/json/encoder.ts +++ b/ambar-core/src/json/encoder.ts @@ -150,15 +150,12 @@ const optionalNullable = ( : Just>(v) ); -// Encode a field that may not be there as a maybe. -const optional = ( - encoder: Encoder> -): Encoder | undefined> => - new Encoder((input) => { +const optional = (encoder: Encoder): EncoderOptional => + optionalMaybe(encoder).rmap((input): Maybe => { if (typeof input === 'undefined') { - return maybe(encoder).run(Nothing()); + return Nothing() as Maybe; } - return maybe(encoder).run(Just(input)); + return Just(input) as Maybe; }); const oneOf = (f: (v: V) => Encoder): Encoder => diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index d5dc58f..42e6aa6 100644 --- a/ambar-core/src/json/schema.ts +++ b/ambar-core/src/json/schema.ts @@ -95,9 +95,6 @@ const both = (left: Schema, right: Schema): Schema<[T, U]> => ); // Schema for an object field that may not be present. -// If it is absent: -// - it will be decoded as 'null'. -// - the encoded object will not contain the relevant key class SchemaOptional { constructor( readonly decoder: D.DecoderOptional, @@ -105,6 +102,10 @@ class SchemaOptional { ) {} } +// An object field that may be absent. +const optional = (s: Schema): SchemaOptional => + new SchemaOptional(D.optional(s.decoder), E.optional(s.encoder)); + const optionalNullable = ( schema: Schema> ): SchemaOptional> => @@ -158,12 +159,6 @@ const map = (s: Schema): Schema> => const maybe = (s: Schema): Schema> => new Schema(D.maybe(s.decoder), E.maybe(s.encoder)); -// An object field that may be absent. -const optional = ( - s: Schema> -): Schema>> => - new Schema(D.optional(s.decoder), E.maybe(s.encoder)); - const nullable = (s: Schema): Schema> => new Schema(D.nullable(s.decoder), E.nullable(s.encoder));