Skip to content

Better Or Chaining #5

@chris-pardy

Description

@chris-pardy

Currently when we chain together 2 or more converters with the or function it creates an issue where we're unsure which branch we should be converting. The solution is to try both and throw an error at the top-level if both fail. However this causes problems when combining complex objects and trying to find the source of the error.

Don't combine complex objects with or

The "simple" solution is to not combine complex objects using the .or function and instead use switch or taggedUnion however that advice fails when you want an object or a primitive for instance: t.number.or(t.strict({ value: t.number, format: t.string }))

What Error should be generated?

Given the converter declared as: t.number.or(t.strict({ value: t.number, format: t.string })) the following table represents what I'd propose as errors:

Input Error
false At $ expected number | object but received false
{ value: 'four', format: 'number' } At $.value expected number but received "four"
{ value: 'four' } At $.value expected number but received "four", At $.format expected string but was missing

Generating these errors:

The heuristic to use for generating errors for or chains should be to find the shallowest path of each side and select the side of the or chain with the deeper path the following table illustrates that from above:

Input Number Error Strict Error
false path: $, expected: number path: $, expected: object
{ value: 'four', format: 'number' } path: $, expected: number path $.value, expected: number
{ value: 'four' } path: $, expected: number path $.value, expected: number AND path $.format, expected: string

In case 2 and 3 above we find that errors from the strict branch are at path depth 2 while the errors from the number branch are at path depth 1, therefore we throw the errors from the strict branch as-is.
In case 1 the branches have the same depth, in this case we need to break a tie. We can find all the errors with the same path and actual value and combine their expected values with the | character. If there are no errors matching that criteria (completely disjoint types) we can generate a new error at the top level.

Generalizing to Union of complex types

This approach of measuring error depth can be applied to the union of complex types, although it probably shouldn't. Let's assume the following converter:

t.strict({
  type: t.literal('shirt'),
  neck: t.number,
  chest: t.number
}).or(t.strict({
  type: t.literal('pants'),
  waist: t.number,
  inseam: t.number
});

Given the input { type: 'dress', bust: 35, waist: 27 } our approach above will produce the following error:

At $.type expected "shirt" | "pants" but was "dress"

this is good.
However given the input: { type: 'shirt', waist: 32, inseam: 34 } our approach will produce the error:

At $ expected { type: "shirt", neck: number, chest: number } | { type: "pants", waist: number, inseam: number } but was { type: "shirt", waist: 32, inseam: 34 }

Without changing our object structure the only other heuristics we have to look at are field names and error counts. The shirt branch is producing 2 errors both at depth 2, one for neck and the other for chest, the pants branch is producing 1 error at depth 2 for type. If we were to consider the branch with the least errors the most likely candidate we would report that we expected "pants" but was "shirt" this is particularly odd, on the other hand selecting the candidate with the most errors also feels wrong as it will likely report on cases which are much more wrong in many cases. Considering field names and giving special standing to fields named "type" or "tag" could help to solve this, however it seems as though simply using a taggedUnion converter is a better approach all-around.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions