io-ts icon indicating copy to clipboard operation
io-ts copied to clipboard

How to decode nested json involving string union literal types

Open kpritam opened this issue 5 years ago • 9 comments

I want to decode following raw json to the Parameter model. I have tried various variations but failed.

Does io-ts support following use case

 // input json to be decoded to Parameter class
 const raw: unknown = {
    IntKey: {
      keyName: 'epoch',
      values: [1, 2, 3]
    }
  }

 // domain models
  type IntKey = {
    KeyTag: 'IntKey'
    KeyType: number
  }
  type StringKey = {
    KeyTag: 'StringKey'
    KeyType: string
  }

  type Key = IntKey | StringKey

  class Parameter<T extends Key> {
    constructor(
      readonly keyName: string,
      readonly keyTag: T['KeyTag'],
      readonly values: T['KeyType'][]
    ) {}
  }

kpritam avatar Jul 09 '20 18:07 kpritam

Answering my own question, I have come up with following code which does exactly what I mentioned in original issue.

Is there any simplification possible?

import * as E from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/function'
import * as t from 'io-ts'

class Parameter<T extends Key> {
  constructor(
    readonly keyName: string,
    readonly keyTag: T['keyTag'],
    readonly values: T['keyType'][]
  ) {}
}

const ParamBodyDecoder = <T>(valuesDec: t.Type<T, unknown>) =>
  t.type({
    keyName: t.string,
    values: t.array(valuesDec)
  })

const MakeKey = <KType extends t.Mixed>(kType: KType) => <KTag extends string>(kTag: KTag) =>
  t.type({
    keyTag: t.literal(kTag),
    keyType: kType,
    paramDecoder: ParamBodyDecoder(kType)
  })

const IntKey = MakeKey(t.number)('IntKey')
const StringKey = MakeKey(t.string)('StringKey')

type IntKey = t.TypeOf<typeof IntKey>
type StringKey = t.TypeOf<typeof StringKey>
type Key = IntKey | StringKey

const Keys = {
  IntKey: IntKey,
  StringKey: StringKey
}
const KeyTag = t.keyof(Keys)
type KeyTag = t.TypeOf<typeof KeyTag>

type ParameterJson<T extends Key> = {
  keyName: string
  values: T['keyType'][]
}
type ParameterJsonResult<T extends Key> = E.Either<t.Errors, ParameterJson<T>>

const decodeKeyTag = (record: Record<string, unknown>) => KeyTag.decode(Object.keys(record)[0])

const decodeParamBody = <T extends Key>(
  keyTag: KeyTag,
  record: Record<string, unknown>
): ParameterJsonResult<T> => Keys[keyTag].props.paramDecoder.decode(record[keyTag])

const toParameter = <T extends Key>(keyTag: KeyTag, rawParam: ParameterJson<T>) =>
  new Parameter(rawParam.keyName, keyTag, rawParam.values)

const decodeParameter = <T extends Key>(input: unknown): t.Validation<Parameter<T>> =>
  pipe(
    t.UnknownRecord.decode(input),
    E.chain((record) =>
      pipe(
        decodeKeyTag(record),
        E.chain((keyTag) =>
          pipe(
            decodeParamBody<T>(keyTag, record),
            E.map((body) => toParameter<T>(keyTag, body))
          )
        )
      )
    )
  )

test('decode parameter', () => {
  // input json to be decoded to Parameter class
  const raw: unknown = {
    IntKey: {
      keyName: 'epoch',
      values: [1, 2, 3]
    }
  }

  console.log(decodeParameter(raw))
})

// ======== OUTPUT ========
{
      _tag: 'Right',
      right: Parameter { keyName: 'epoch', keyTag: 'IntKey', values: [ 1, 2, 3 ] }
}

kpritam avatar Jul 11 '20 09:07 kpritam

Is there any simplification possible?

@kpritam I would define a decoder for the raw input (I'm guessing):

const decoder = t.partial({
  IntKey: t.type({
    keyName: t.string,
    values: t.array(t.number)
  }),
  StringKey: t.type({
    keyName: t.string,
    values: t.array(t.string)
  })
})

and then map the result

const decodeParameters = (input: unknown): t.Validation<Array<Parameter<Key>>> =>
  pipe(
    decoder.decode(input),
    E.map((a) => {
      const out: Array<Parameter<Key>> = []
      if (a.IntKey !== undefined) {
        out.push(new Parameter(a.IntKey.keyName, 'IntKey', a.IntKey.values))
      }
      if (a.StringKey !== undefined) {
        out.push(new Parameter(a.StringKey.keyName, 'StringKey', a.StringKey.values))
      }
      return out
    })
  )

gcanti avatar Jul 14 '20 06:07 gcanti

@gcanti thanks for the response.

This was the minimal example, but in real case, we have more than 15 such a keys (IntKey, StringKey, BooleanKey, IntArrayKey etc.).

We are also using independent static types like IntKey, StringKey etc. generated from io-ts runtime types in different places in our code bases.

Hence I am first decoding index key and based on that using appropriate corresponding decoder to decode rest of the body. In order to do that, I had to keep all the keys in record so that I can retrieve correct decoder based on provided key. I am not able to get rid this record somehow.

const Keys = {
  IntKey: IntKey,
  StringKey: StringKey
}

kpritam avatar Jul 14 '20 06:07 kpritam

I see, but what I meant is that without the schema of the raw input is hard to say something and help you out.

A single example

// input json to be decoded to Parameter class
 const raw: unknown = {
    IntKey: {
      keyName: 'epoch',
      values: [1, 2, 3]
    }
  }

is not enough.

Here you are picking the first key only

const decodeKeyTag = (record: Record<string, unknown>) => KeyTag.decode(Object.keys(record)[0])

does it mean that the input schema is a union instead?

const decoder = t.union([
  t.type({
    IntKey: t.type({
      keyName: t.string,
      values: t.array(t.number)
    })
  }),
  t.type({
    StringKey: t.type({
      keyName: t.string,
      values: t.array(t.string)
    })
  }),
  ... etc ...
])

I can just make arbitrary guesses

gcanti avatar Jul 14 '20 06:07 gcanti

Input schema is this :

{ paramSet: Parameter<Key>[] }

Note: StructKey is recursive which is nothing but { paramSet: Parameter<Key>[] }

Complete sample json file looks like this:

 {
      paramSet: [
        {
          LongKey: {
            keyName: 'LongKey',
            values: [50, 60],
            units: 'NoUnits'
          }
        },
        {
          RaDecKey: {
            keyName: 'RaDecKey',
            values: [
              {
                ra: 7.3,
                dec: 12.1
              }
            ],
            units: 'NoUnits'
          }
        },
        {
          FloatKey: {
            keyName: 'FloatKey',
            values: [90, 100],
            units: 'NoUnits'
          }
        },
        {
          StructKey: {
            keyName: 'StructKey',
            values: [
              {
                paramSet: [
                  {
                    BooleanKey: {
                      keyName: 'BooleanKey',
                      values: [true, false],
                      units: 'NoUnits'
                    }
                  },
                  {
                    ByteKey: {
                      keyName: 'ByteKey',
                      values: [10, 20],
                      units: 'NoUnits'
                    }
                  }
                ]
              }
            ],
            units: 'NoUnits'
          }
        },
        {
          IntArrayKey: {
            keyName: 'IntArrayKey',
            values: [[7, 8]],
            units: 'NoUnits'
          }
        },
        {
          IntMatrixKey: {
            keyName: 'IntMatrix',
            values: [
              [
                [12, 13],
                [14, 15]
              ]
            ],
            units: 'NoUnits'
          }
        },
        {
          LongArrayKey: {
            keyName: 'LongArrayKey',
            values: [[5, 6]],
            units: 'NoUnits'
          }
        },
        {
          ShortKey: {
            keyName: 'ShortKey',
            values: [30, 40],
            units: 'NoUnits'
          }
        },
        {
          LongMatrixKey: {
            keyName: 'LongMatrix',
            values: [
              [
                [8, 9],
                [10, 11]
              ]
            ],
            units: 'NoUnits'
          }
        },
        {
          ByteMatrixKey: {
            keyName: 'ByteMatrix',
            values: [
              [
                [1, 2],
                [3, 4]
              ]
            ],
            units: 'NoUnits'
          }
        },
        {
          ShortArrayKey: {
            keyName: 'ShortArrayKey',
            values: [[3, 4]],
            units: 'NoUnits'
          }
        },
        {
          CoordKey: {
            keyName: 'CoordKey',
            values: [
              {
                _type: 'EqCoord',
                tag: 'BASE',
                ra: 659912250000,
                dec: -109892300000,
                frame: 'ICRS',
                catalogName: 'none',
                pm: {
                  pmx: 0.5,
                  pmy: 2.33
                }
              },
              {
                _type: 'SolarSystemCoord',
                tag: 'BASE',
                body: 'Venus'
              },
              {
                _type: 'MinorPlanetCoord',
                tag: 'GUIDER1',
                epoch: 2000,
                inclination: 324000000000,
                longAscendingNode: 7200000000,
                argOfPerihelion: 360000000000,
                meanDistance: 1.4,
                eccentricity: 0.234,
                meanAnomaly: 792000000000
              },
              {
                _type: 'CometCoord',
                tag: 'BASE',
                epochOfPerihelion: 2000,
                inclination: 324000000000,
                longAscendingNode: 7200000000,
                argOfPerihelion: 360000000000,
                perihelionDistance: 1.4,
                eccentricity: 0.234
              },
              {
                _type: 'AltAzCoord',
                tag: 'BASE',
                alt: 1083600000000,
                az: 153000000000
              }
            ],
            units: 'NoUnits'
          }
        },
        {
          ByteKey: {
            keyName: 'ByteKey',
            values: [10, 20],
            units: 'NoUnits'
          }
        },
        {
          DoubleKey: {
            keyName: 'DoubleKey',
            values: [110, 120],
            units: 'NoUnits'
          }
        },
        {
          DoubleArrayKey: {
            keyName: 'DoubleArrayKey',
            values: [[11, 12]],
            units: 'NoUnits'
          }
        },
        {
          DoubleMatrixKey: {
            keyName: 'DoubleMatrix',
            values: [
              [
                [20, 21],
                [22, 23]
              ]
            ],
            units: 'NoUnits'
          }
        },
        {
          ByteArrayKey: {
            keyName: 'ByteArrayKey',
            values: [[1, 2]],
            units: 'NoUnits'
          }
        },
        {
          CharKey: {
            keyName: 'CharKey',
            values: ['65', '66'], // fixme: CharKey comes as number from scala
            units: 'NoUnits'
          }
        },
        {
          IntKey: {
            keyName: 'IntKey',
            values: [70, 80],
            units: 'NoUnits'
          }
        },
        {
          UTCTimeKey: {
            keyName: 'UTCTimeKey',
            values: ['1970-01-01T00:00:00Z', '2017-09-04T19:00:00.123456789Z'],
            units: 'second'
          }
        },
        {
          ChoiceKey: {
            keyName: 'ChoiceKey',
            values: ['First', 'Second'],
            units: 'NoUnits'
          }
        },
        {
          FloatArrayKey: {
            keyName: 'FloatArrayKey',
            values: [[9, 10]],
            units: 'NoUnits'
          }
        },
        {
          StringKey: {
            keyName: 'StringKey',
            values: ['Str1', 'Str2'],
            units: 'NoUnits'
          }
        },
        {
          BooleanKey: {
            keyName: 'BooleanKey',
            values: [true, false],
            units: 'NoUnits'
          }
        },
        {
          TAITimeKey: {
            keyName: 'TAITimeKey',
            values: ['1970-01-01T00:00:00Z', '2017-09-04T19:00:00.123456789Z'],
            units: 'second'
          }
        },
        {
          ShortMatrixKey: {
            keyName: 'ShortMatrix',
            values: [
              [
                [4, 5],
                [6, 7]
              ]
            ],
            units: 'NoUnits'
          }
        },
        {
          FloatMatrixKey: {
            keyName: 'FloatMatrix',
            values: [
              [
                [16, 17],
                [18, 19]
              ]
            ],
            units: 'NoUnits'
          }
        }
      ]
    }

kpritam avatar Jul 14 '20 08:07 kpritam

So the input schema is something along the lines of

// ---------------------------
// input schema
// ---------------------------

type InputKey<L extends string, V> = Record<L, { keyName: L; values: Array<V> }>

interface LongKey extends InputKey<'LongKey', number> {}

interface RaDecKey
  extends InputKey<
    'RaDecKey',
    {
      ra: number
      dec: number
    }
  > {}

interface FloatKey extends InputKey<'FloatKey', number> {}

interface BooleanKey extends InputKey<'BooleanKey', boolean> {}

interface ByteKey extends InputKey<'ByteKey', number> {}

interface StructKey extends InputKey<'StructKey', Input> {}

type Param = LongKey | RaDecKey | FloatKey | StructKey | BooleanKey | ByteKey // more members...

interface Input {
  paramSet: Array<Param>
}

and this is a one-to-one translation to decoders

// ---------------------------
// input decoders
// ---------------------------

const inputKey = <L extends string, V>(literal: L, values: D.Decoder<unknown, V>): D.Decoder<unknown, InputKey<L, V>> =>
  D.type({
    [literal]: D.type({
      keyName: D.literal(literal),
      values: D.array(values)
    })
  }) as any

const LongKey: D.Decoder<unknown, LongKey> = inputKey('LongKey', D.number)

const RaDecKey: D.Decoder<unknown, RaDecKey> = inputKey(
  'RaDecKey',
  D.type({
    ra: D.number,
    dec: D.number
  })
)

const FloatKey: D.Decoder<unknown, FloatKey> = inputKey('FloatKey', D.number)

const BooleanKey: D.Decoder<unknown, BooleanKey> = inputKey('BooleanKey', D.boolean)

const ByteKey: D.Decoder<unknown, ByteKey> = inputKey('ByteKey', D.number)

const StructKey: D.Decoder<unknown, StructKey> = D.lazy('StructKey', () => inputKey('StructKey', Input))

const Param = D.union(LongKey, RaDecKey, FloatKey, StructKey, BooleanKey, ByteKey)

const Input: D.Decoder<unknown, Input> = D.type<Input>({
  paramSet: D.array(Param)
})

const input: Input = {
  paramSet: [
    {
      LongKey: {
        keyName: 'LongKey',
        values: [50, 60]
      }
    },
    {
      RaDecKey: {
        keyName: 'RaDecKey',
        values: [
          {
            ra: 7.3,
            dec: 12.1
          }
        ]
      }
    },
    {
      FloatKey: {
        keyName: 'FloatKey',
        values: [90, 100]
      }
    },
    {
      StructKey: {
        keyName: 'StructKey',
        values: [
          {
            paramSet: [
              {
                BooleanKey: {
                  keyName: 'BooleanKey',
                  values: [true, false]
                }
              },
              {
                ByteKey: {
                  keyName: 'ByteKey',
                  values: [10, 20]
                }
              }
            ]
          }
        ]
      }
    }
  ]
}

console.log(Input.decode(input))
/*
{
  _tag: 'Right',
  right: { paramSet: [ [Object], [Object], [Object], [Object] ] }
}
*/

This would be a possible solution if you just want to validate the input, if I understand correctly you want to decode the input to a different domain model instead

// ---------------------------
// domain
// ---------------------------

interface DomainKey<L extends string, V> {
  keyName: L
  values: Array<V>
}

interface DomainLongKey extends DomainKey<'LongKey', number> {}

interface DomainRaDecKey
  extends DomainKey<
    'LongKey',
    {
      ra: number
      dec: number
    }
  > {}

interface DomainFloatKey extends DomainKey<'FloatKey', number> {}

interface DomainBooleanKey extends DomainKey<'BooleanKey', boolean> {}

interface DomainByteKey extends DomainKey<'ByteKey', number> {}

interface DomainStructKey extends DomainKey<'StructKey', DomainParams> {}

type DomainParam = DomainLongKey | DomainRaDecKey | DomainFloatKey | DomainStructKey | DomainBooleanKey | DomainByteKey // more members...

type DomainParams = Array<DomainParam>

so you can use map

// ---------------------------
// domain decoders
// ---------------------------

import { pipe } from 'fp-ts/lib/function'

const domainKey = <L extends string, V>(
  literal: L,
  values: D.Decoder<unknown, V>
): D.Decoder<unknown, DomainKey<L, V>> =>
  pipe(
    inputKey(literal, values),
    D.map((a) => a[literal]) // <= map to the domain model
  )

const DomainLongKey: D.Decoder<unknown, DomainLongKey> = domainKey('LongKey', D.number)

const DomainRaDecKey: D.Decoder<unknown, DomainRaDecKey> = domainKey(
  'RaDecKey',
  D.type({
    ra: D.number,
    dec: D.number
  })
)

const DomainFloatKey: D.Decoder<unknown, DomainFloatKey> = domainKey('FloatKey', D.number)

const DomainBooleanKey: D.Decoder<unknown, DomainBooleanKey> = domainKey('BooleanKey', D.boolean)

const DomainByteKey: D.Decoder<unknown, DomainByteKey> = domainKey('ByteKey', D.number)

const DomainStructKey: D.Decoder<unknown, DomainStructKey> = D.lazy('StructKey', () =>
  domainKey('StructKey', DomainParams)
)

const DomainParam = D.union(
  DomainLongKey,
  DomainRaDecKey,
  DomainFloatKey,
  DomainStructKey,
  DomainBooleanKey,
  DomainByteKey
)

const DomainParams: D.Decoder<unknown, DomainParams> = pipe(
  D.type({
    paramSet: D.array(DomainParam)
  }),
  D.map((x) => x.paramSet) // <= map to the domain model
)

console.log(DomainParams.decode(input))
/*
{
  _tag: 'Right',
  right: [
    { keyName: 'LongKey', values: [Array] },
    { keyName: 'RaDecKey', values: [Array] },
    { keyName: 'FloatKey', values: [Array] },
    { keyName: 'StructKey', values: [Array] }
  ]
}
*/

gcanti avatar Jul 14 '20 12:07 gcanti

@gcanti Thank you for elaborate sample, this works well.

One question though, in case of invalid data, for example, if I pass strings in values for IntKey, it will try to decode with all the keys? To avoid multiple parsing for invalid data, I was keeping Keys -> Decoder record and based on key, I was extracting corresponding decoder to avoid decode attempt with rest of the keys as they are anyway going to fail.

In above case, I see following output:

//  -------------------
// Input
//  -------------------
const intParam = {
      paramSet: [
        {
          IntKey: {
            keyName: 'epoch',
            values: ['1', 2, 3]
          }
        }
      ]
    }

const decoded = Input.decode(intParam)
if (isLeft(decoded)) console.log(D.draw(decoded.left))

//  -------------------
// Output
//  -------------------
required property "paramSet"
    └─ optional index 0
       ├─ member 0
       │  └─ required property "IntKey"
       │     └─ required property "values"
       │        └─ optional index 0
       │           └─ cannot decode "1", should be number
       ├─ member 1
       │  └─ required property "LongKey"
       │     └─ cannot decode undefined, should be Record<string, unknown>
       ├─ member 2
       │  └─ required property "RaDecKey"
       │     └─ cannot decode undefined, should be Record<string, unknown>
       ├─ member 3
       │  └─ required property "FloatKey"
       │     └─ cannot decode undefined, should be Record<string, unknown>
       ├─ member 4
       │  └─ lazy type StructKey
       │     └─ required property "StructKey"
       │        └─ cannot decode undefined, should be Record<string, unknown>
       ├─ member 5
       │  └─ required property "BooleanKey"
       │     └─ cannot decode undefined, should be Record<string, unknown>
       └─ member 6
          └─ required property "ByteKey"
             └─ cannot decode undefined, should be Record<string, unknown>

kpritam avatar Jul 14 '20 15:07 kpritam

[Reference] Here is the complete implementation that we currently have with the approach I mentioned in the beginning:

https://github.com/tmtsoftware/esw-ts/blob/pritam/io-ts-decoder/lib/src/models/params/Key.ts https://github.com/tmtsoftware/esw-ts/blob/pritam/io-ts-decoder/lib/src/models/params/Parameter.ts

kpritam avatar Jul 14 '20 15:07 kpritam

@kpritam you are right, DomainParam

const DomainParam = D.union(
  DomainLongKey,
  DomainRaDecKey,
  DomainFloatKey,
  DomainStructKey,
  DomainBooleanKey,
  DomainByteKey
)

can be optimized

gcanti avatar Jul 14 '20 16:07 gcanti