Skip to content

Input vs. Output paths #6

@chris-pardy

Description

@chris-pardy

Currently our path converters t.forPath work on input paths meaning that if you have the following converter:

const converter = t.strict({
   height: t.number.default(t.forPath([t.ParentPath, 'width'])),
   width: t.number.default(() => 0)
});

and give it {} as input you'll get the following error:

At $.height expected number but was undefined

This is confusing because you've defined a default value for the height, however that default value references another field (width) that itself has a default value. This means that you've implicitly created a dependency where if width is supplied height is optional, and if height is supplied width is optional. This is likely not intended, and in less trivial examples it may go unnoticed.
If the path operations were able to operate on "outputs" rather than inputs the above code would work as expected, width would receive it's default value, and height would receive it's default value from width.

Problems with Output pathing

Output based pathing has the problem that it requires fields to exist on the output to use them as input in default values, consider the following case:

const converter = t.strict({
    width: t.number.default(() => 0),
    diagonal: t.number.default(t.compose([
        t.forPath([t.ParentPath, 'height'], t.number.default(() => 0)),
        t.forPath([t.ParentPath, 'width'])
    ], (height: number, width: number) => Math.sqrt(Math.pow(height, 2) + Math.pow(width, 2)))
      )
});

In this case we're inferring a new field diagonal from 2 current field (width) and one legacy field (height). However because the legacy field doesn't exist in the output how will it be determined?

Option 1: deprecated fields
We could present legacy fields as deprecated fields that were not on the actual output, but could be reached via output paths. eg.

const converter = t.strict({
    width: t.number.default(() => 0),
    height: t.deprecated(t.number.default(() => 0)),
    diagonal: t.number.default(t.compose([
        t.forPath([t.ParentPath, 'height']),
        t.forPath([t.ParentPath, 'width'])
    ], (height: number, width: number) => Math.sqrt(Math.pow(height, 2) + Math.pow(width, 2)))
      )
});

Height will not be included in the output but will be accessible via the t.forPath and allows for value defaulting, including pulling from other properties.
This has the advantage of simplifying the process of migrating a field to first deprecating the old field, and then referencing it in a new one. The downside of this approach is that fields that may never have existed on the output would need to be added as deprecated fields, or worse fields that exist with different meanings on the input and output are nearly impossible to reference. These downsides apply largely to converters that adapt types that are disjointed, ie. A => B instead of A1 => A2

Option 2: Input Paths
While the standard path approach would reference output paths a special input path type could be created, leveraging the syntax from #3 here is an example.

const converter = t.strict({
    width: t.number.default(() => 0),
    diagonal: t.number.default(t.compose([
        t.input`^.height`.pipe(t.number.default(() => 0)),
        t.forPath([t.ParentPath, 'width'])
    ], (height: number, width: number) => Math.sqrt(Math.pow(height, 2) + Math.pow(width, 2)))
      )
});

In this case we've specifically called out that the value should be coming from the input, instead of the output. This has the advantage that it can work in the case of a disjointed type converter.

Proposal

I propose that both options are effectively adopted. The path converter should operate on an "output" and allow relative paths (parent, current). These can refer to deprecated fields which are converted "on-demand" when they are referenced. This is important because deprecated fields should only throw an error in cases where they are accessed, even if they are present and incorrect. Additionally a path can be created that references the "input root" (create a new root indicator for this for instance $$) There is an inherently odd behavior of mapping a structure to the output and then referencing relative inputs therefore we should simply not support the relative versions of input paths.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions