Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions ambar-core/src/json/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export {
Decoder,
type DecoderDef,
type DecodeResult,
type DecoderOptionalNullable,
type DecoderOptionalMaybe,
decode,
object,
objectMap,
Expand All @@ -54,6 +56,8 @@ export {
optional,
succeed,
both,
optionalNullable,
optionalMaybe,
};

import { Result, Success, Failure, traverse } from '../result';
Expand Down Expand Up @@ -158,7 +162,10 @@ const array = <V>(decodeValue: Decoder<V>): Decoder<Array<V>> =>
});

type DecoderDef<A> = {
[P in keyof A]: Decoder<A[P]>;
[P in keyof A]:
| Decoder<A[P]>
| DecoderOptionalNullable<A[P]>
| DecoderOptionalMaybe<A[P]>;
};

// Ignores extra properties.
Expand All @@ -171,7 +178,24 @@ const object = <A>(decoders: DecoderDef<A>): Decoder<A> =>

const result = {} as A;
for (const field in decoders) {
const decoder = decoders[field];
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.run(obj[field]);
switch (true) {
case decoded instanceof Success:
Expand Down Expand Up @@ -283,8 +307,8 @@ const oneOf = <T extends Decoder<any>[]>(decoders: T): T[number] =>

const maybe = <V>(decoder: Decoder<V>): Decoder<Maybe<V>> =>
oneOf([
nullP.map((_) => Nothing()) as Decoder<Maybe<V>>,
decoder.map(Just<V>),
object({ nothing: object({}) }).map((_) => Nothing<V>()),
object({ just: decoder }).map((v) => Just(v.just)),
]);

const nullable = <V>(decoder: Decoder<V>): Decoder<Nullable<V>> =>
Expand Down Expand Up @@ -313,6 +337,35 @@ const optional = <V>(decoder: Decoder<V>): Decoder<Maybe<V>> =>
undefinedP.map((_) => Nothing()) as Decoder<Maybe<V>>,
]);

// Decoder for a field that may not be present.
// If it is absent it will be decoded as 'null'.
class DecoderOptionalNullable<A> {
private constructor(readonly present: Decoder<A>) {}
static from<A>(
d: Decoder<NonNullable<A>>
): DecoderOptionalNullable<Nullable<A>> {
return new DecoderOptionalNullable(d);
}
}

const optionalNullable = <V>(
decoder: Decoder<NonNullable<V>>
): DecoderOptionalNullable<Nullable<V>> =>
DecoderOptionalNullable.from(decoder);

// Decoder for a field that may not be present.
// If it is absent it will be decoded as 'Nothing()'.
class DecoderOptionalMaybe<A> {
private constructor(readonly present: Decoder<A>) {}
static from<A>(d: Decoder<A>): DecoderOptionalMaybe<Maybe<A>> {
return new DecoderOptionalMaybe(d.map(Just<A>));
}
}

const optionalMaybe = <V>(
decoder: Decoder<V>
): DecoderOptionalMaybe<Maybe<V>> => DecoderOptionalMaybe.from(decoder);

// Define a recursive decoder
function rec<A>(f: (p: Decoder<A>) => Decoder<A>): Decoder<A> {
const base: Decoder<A> = fail(
Expand Down
85 changes: 78 additions & 7 deletions ambar-core/src/json/encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export {
type Infer,
Encoder,
type EncoderDef,
type EncoderOptionalNullable,
type EncoderOptionalMaybe,
json,
boolean,
number,
Expand All @@ -20,6 +22,8 @@ export {
oneOf,
both,
stringEnum,
optionalNullable,
optionalMaybe,
};

import { Maybe, Nothing, Just, Nullable } from '../maybe';
Expand All @@ -41,7 +45,10 @@ class Encoder<A> {
}

type EncoderDef<A> = {
[P in keyof A]: Encoder<A[P]>;
[P in keyof A]:
| Encoder<A[P]>
| EncoderOptionalNullable<A[P]>
| EncoderOptionalMaybe<A[P]>;
};

const toAny = <T extends Json>(): Encoder<T> => new Encoder((v) => v);
Expand All @@ -66,8 +73,29 @@ const object = <A>(encoders: EncoderDef<A>): Encoder<A> =>
const result = {} as JsonObject;
for (const field in encoders) {
const encoder = encoders[field];
const encoded = encoder.run(input[field]);
result[field] = encoded;
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:
if (
!(input[field] instanceof Nothing) &&
input[field] !== undefined
) {
const encoded = encoder.present.run(input[field]);
result[field] = encoded;
}
break;
case encoder instanceof Encoder:
const encoded = encoder.run(input[field]);
result[field] = encoded;
break;
default:
encoder satisfies never;
}
}

return result;
Expand All @@ -89,16 +117,59 @@ const triple = <A, B, C>(
return [sA.run(a), sB.run(b), sC.run(c)];
});

const maybe = <V>(
encoder: Encoder<NonNullable<V>>
): Encoder<Maybe<NonNullable<V>>> =>
const maybe = <V>(encoder: Encoder<V>): Encoder<Maybe<V>> =>
new Encoder((input) =>
input instanceof Nothing ? null : encoder.run(input.value)
input instanceof Nothing
? { nothing: {} }
: { just: encoder.run(input.value) }
);

const nullable = <V>(encoder: Encoder<V>): Encoder<Nullable<V>> =>
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<A> {
private constructor(readonly present: Encoder<A>) {}

static from<T>(
e: Encoder<NonNullable<T>>
): EncoderOptionalNullable<T | null> {
const encoder = new Encoder<T | null>((input) => {
if (input == null) {
throw new Error('EncoderOptionalNullable called with null');
}
return e.run(input);
});

return new EncoderOptionalNullable<T | null>(encoder);
}
}

const optionalNullable = <V>(
encoder: Encoder<NonNullable<V>>
): EncoderOptionalNullable<Nullable<V>> =>
EncoderOptionalNullable.from(encoder);

// An encoder for object keys that omits the field if the value is Nothing.
class EncoderOptionalMaybe<A> {
private constructor(readonly present: Encoder<A>) {}

static from<T>(e: Encoder<T>): EncoderOptionalMaybe<Maybe<T>> {
const encoder = new Encoder<Maybe<T>>((input) => {
if (input instanceof Nothing) {
throw new Error('EncoderOptionalMaybe called with Nothing()');
}
return e.run(input.value);
});

return new EncoderOptionalMaybe(encoder);
}
}

const optionalMaybe = <V>(
encoder: Encoder<V>
): EncoderOptionalMaybe<Maybe<V>> => EncoderOptionalMaybe.from(encoder);

// Encode a field that may not be there as a maybe.
const optional = <V>(
encoder: Encoder<NonNullable<V>>
Expand Down
45 changes: 43 additions & 2 deletions ambar-core/src/json/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export {
maybe,
nullable,
optional,
optionalNullable,
optionalMaybe,
stringLiteral,
stringEnum,
oneOf,
Expand Down Expand Up @@ -74,7 +76,10 @@ function from<A>(decoder: Decoder<A>, encoder: Encoder<A>): Schema<A> {
}

type SchemaDef<A> = {
[D in keyof A]: Schema<A[D]>;
[D in keyof A]:
| Schema<A[D]>
| SchemaOptionalNullable<A[D]>
| SchemaOptionalMaybe<A[D]>;
};

const json: Schema<Json> = new Schema(D.json, E.json);
Expand All @@ -91,6 +96,42 @@ const both = <T, U>(left: Schema<T>, right: Schema<U>): Schema<[T, U]> =>
E.both(left.encoder, right.encoder)
);

// 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 SchemaOptionalNullable<A> {
constructor(
readonly decoder: D.DecoderOptionalNullable<A>,
readonly encoder: E.EncoderOptionalNullable<A>
) {}
}

const optionalNullable = <A>(
schema: Schema<NonNullable<A>>
): SchemaOptionalNullable<Nullable<A>> =>
new SchemaOptionalNullable(
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<A> {
constructor(
readonly decoder: D.DecoderOptionalMaybe<A>,
readonly encoder: E.EncoderOptionalMaybe<A>
) {}
}

const optionalMaybe = <A>(schema: Schema<A>): SchemaOptionalMaybe<Maybe<A>> =>
new SchemaOptionalMaybe(
D.optionalMaybe(schema.decoder),
E.optionalMaybe(schema.encoder)
);

function object<A>(def: SchemaDef<A>): Schema<A> {
const pdef = {} as DecoderDef<A>;
const sdef = {} as EncoderDef<A>;
Expand Down Expand Up @@ -127,7 +168,7 @@ const map = <A>(s: Schema<A>): Schema<Map<string, A>> =>
(m) => Array.from(m.entries())
);

const maybe = <A>(s: Schema<NonNullable<A>>): Schema<Maybe<NonNullable<A>>> =>
const maybe = <A>(s: Schema<A>): Schema<Maybe<A>> =>
new Schema(D.maybe(s.decoder), E.maybe(s.encoder));

// An object field that may be absent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Optional schema cannot decode its own encoded Just values

The optional schema still wires D.optional (which only accepts a raw value or undefined) to E.maybe, but E.maybe now emits { nothing: {} }/{ just: ... } objects. After this change, encoding s.optional(s.string) with Just('x') produces { just: 'x' }, which D.optional rejects because it no longer matches either branch, so the schema can no longer roundtrip even non-empty optionals. Any use of s.optional will now fail to decode values it encodes.

Useful? React with 👍 / 👎.

Expand Down
6 changes: 6 additions & 0 deletions ambar-core/src/maybe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ class Just<T> implements IMaybe<T> {

readonly value : T;
constructor(v: T) { this.value = v; }
toString() {
return `Just(${this.value})`;
}

isJust() { return true; }
isNothing() { return false; }
Expand All @@ -70,6 +73,9 @@ class Just<T> implements IMaybe<T> {
class Nothing<T> implements IMaybe<T> {
static new<T>() : Nothing<T> { return new Nothing(); }
constructor() {}
toString() {
return "Nothing()";
}

isJust() { return false; }
isNothing() { return true; }
Expand Down
Loading