gillchristian/io-ts-reporters

Use Elm format

OliverJAsh opened this issue · 5 comments

Elm sets a really good example of friendly messages for validation (decode) errors:

import Json.Decode exposing (..)

decodeString (list (list int)) "[ [0], [1,2,3], [4,5] ]"
-- Ok [[0],[1,2,3],[4,5]] : Result.Result String (List (List Int))

decodeString (list (list int)) "f"
-- Err "Given an invalid JSON: Unexpected end of input"
--     : Result.Result String (List (List Int))

decodeString (list (list int)) "{}"
-- Err "Expecting a List but instead got: {}"
--     : Result.Result String (List (List Int))

decodeString (list (list int)) "[[{}]]"
-- Err "Expecting an Int at _[0][0] but instead got: {}"
--     : Result.Result String (List (List Int))

decodeString (list (field "foo" int)) "[{ \"foo\": 1 },{ \"foo\": {} }]"
-- Err "Expecting an Int at _[1].foo but instead got: {}"
--     : Result.Result String (List Int)

decodeString (list (field "bar" string)) "[{}]"
-- Err "Expecting an object with a field named `bar` at _[0] but instead got: {}"
--     : Result.Result String (List String)

type alias Point = { x : Int, y : Int }
pointDecoder = map2 Point (field "x" int) (field "y" int)
-- <decoder> : Json.Decode.Decoder Repl.Point
decodeString pointDecoder  """{ "x": 3, "y": 4 }"""
-- Ok { x = 3, y = 4 } : Result.Result String Repl.Point
decodeString pointDecoder """{ "z": 3, "f": 4 }"""
-- Err "Expecting an object with a field named `x` but instead got: {\"z\":3,\"f\":4}"
--     : Result.Result String Repl.Point

References

To do

  • JSON.stringify value instead of .toString, e.g. {} instead of [object Object].

/cc @gcanti

I am trying to reproduce similar messages to Elm’s decoding, for example:

decodeString (list (field "foo" int)) "[{ \"foo\": 1 },{ \"foo\": {} }]"
-- Err "Expecting an Int at _[1].foo but instead got: {}"
--     : Result.Result String (List Int)

I can get very close. Given:

const FooObjects = t.array(t.interface({ foo: t.number }));

t.validate([{ foo: 1 }, { foo: {} }], FooObjects);

With my changes in 741f16f, I get:

Expecting number at 1.foo but instead got: {}.

I would like the reporter to format the path based on whether it was a field or index lookup, like we see in the Elm message. So, instead of 1.foo, this would be [1].foo.

However, I don't think there's anything in the validation error context I can use to detect what the type is (field/index) type.name would work some of the time, but can be easily overridden. @gcanti, would it be possible to include this information somewhere on the context?

For reference, here is the JavaScript for how Elm generates its error messages: https://github.com/elm-lang/core/blob/18c9e84e975ed22649888bfad15d1efdb0128ab2/src/Native/Json.js#L199

would it be possible to include this information somewhere on the context?

It depends on what "being a field or an index" means. If "being an index" means that we are talking about arrays and tuples, the current context shape should already contain that information, stored in the type fields

function isValidPropertyKey(s: string): boolean {
  return /[-\/\s]/.exec(s) === null
}

function isIndex<T>(type: t.Type<T>): boolean {
  return type instanceof t.ArrayType || type instanceof t.TupleType
}

function getKey(key: string, isIndex: boolean): { isEscaped: boolean, key: string } {
  if (isIndex) {
    return { isEscaped: true, key: `[${key}]` }
  }
  if (!isValidPropertyKey(key)) { // <= another case to handle
    return { isEscaped: true, key: `["${key}"]` }
  }
  return { isEscaped: false, key }
}

function getPath(entry: t.Context): string {
  let ret = ''
  for (let i = 1; i < entry.length; i++) {
    const { isEscaped, key } = getKey(entry[i].key, isIndex(entry[i - 1].type))
    ret += isEscaped ? key : '.' + key
  }
  return ret
}

function getMessage(error: t.ValidationError): string {
  const len = error.context.length
  const value = JSON.stringify(error.value)
  const type = error.context[len - 1].type.name
  if (len === 1) {
    return `Expecting a ${type} but instead got: ${value}`
  }
  const path = getPath(error.context)
  return `Expecting a ${type} at ${path} but instead got: ${value}`
}

function report<T>(validation: t.Validation<T>): string {
  return validation.fold(
    errors => errors.map(getMessage).join('\n'),
    () => 'no errors'
  )
}

const FooObjects = t.array(t.interface({ foo: t.number, 'bar baz': t.string, baz: t.array(t.string) }, 'Foo'));

const validation1 = t.validate(1, FooObjects);
console.log(report(validation1))
/*
Expecting a Array<Foo> but instead got: 1
*/

const validation2 = t.validate([{ foo: 1 }, { foo: {}, baz: [1] }], FooObjects);
console.log(report(validation2))
/*
Expecting a string at [0]["bar baz"] but instead got: undefined
Expecting a Array<string> at [0].baz but instead got: undefined
Expecting a number at [1].foo but instead got: {}
Expecting a string at [1]["bar baz"] but instead got: undefined
Expecting a string at [1].baz[0] but instead got: 1
*/

Some of this work is now merged and released. Still room for improvement, though.

I think this is mostly in place already, probably can be closed too? @gillchristian

Yes, I think the only missing peace is using brackets for array items (eg. foo[0].bar), could be a separate issue.