SE-0489 followup: formatting EncodingError and DecodingError's debug descriptions

Hi all! In the acceptance post for SE-0489: Improve EncodingError and DecodingError's printed descriptions, @xwu requested:

To that end, here's a thread where we can talk about it! If you're coming from the original review thread, please keep reading for the summary of changes I've made since then:

:hammer_and_wrench: Changes to the implementation pull request:

  • Changed the debug description to one line so we don't interfere with existing logging systems that expect debugDescription to produce single-line output. This is not a hard-and-fast contract, but it was decided to be the most pragmatic approach to avoid breaking things.
  • Included the exact name of the type (EncodingError and DecodingError) and and case that is generating the description.

:speaking_head: In Scope for Discussion

Please weigh in with your opinions on the following!

  • How should we format paths? they/are/[0]/currently/[3]/like/this, but folks had other thoughts in the review thread, such as using different delimiters or different formatting for integer keys. Which should we use, and why?
  • How much do we care about keys containing special characters, whitespace, the empty string, and the like? I don't think we should spend too much time on them, but if there are things we can do to make the output both clear and compact, it may be worth it.
  • What order should the parts of the error message be printed in, and should we omit empty portions of the description, such as the path, to prefer compactness and readability over uniformity?
  • What should delimit the different parts of the debug description? I opted for . (period-space) as the delimiter, with no period at the end, but that can lead to weirdness like .. or !. depending on what is included in the different portions.
  • What kinds of escaping might we need to consider for characters inside the error messages?

:stop_sign: Out of scope

Please keep this thread on-topic by avoiding these topics, which would have to happen in a separate Foundation proposal:

  1. We can't change the default description of CodingKey that Foundation is using to construct the errors, so we have to live with them for now. We can consider improvements to Foundation in a future proposal.
  2. We can't change the behavior of the encoders/decoders to include more surrounding context. We're just changing the formatting of the error types that we already have.
  3. The output format is intended to help humans debug errors, and especially to make the existing errors more readable. It is not intended to make a machine-readable, parseable format! If you need that, you can interact with the error values directly.

For more detail on the above, please refer to the "Future Directions" section of SE-0489.

:ballot_box_with_ballot: Discussion Format

I don't know the best way to talk about this formatting. As a starting point, I have included examples of the debugDescription of the errors in the description of the implementation pull request. I've been constructing those manually every time I update the pull request via some multi-cursor text editing magic. But I'm open to meta-suggestions for how to talk about formatting suggestions!

:memo: First Draft

Here are my proposed debugDescription formats from the pull request. I intend to keep the pull request updated, but the following can serve as a snapshot of where the discussion started:

👀👀👀 Click to expand 👀👀👀

test_encodingError_invalidValue_nonEmptyCodingPath_nilUnderlyingError

Before

invalidValue(234, Swift.EncodingError.Context(codingPath: [GenericCodingKey(stringValue: "first", intValue: nil), GenericCodingKey(stringValue: "second", intValue: nil), GenericCodingKey(stringValue: "2", intValue: 2)], debugDescription: "You cannot do that!", underlyingError: nil))

After

EncodingError.invalidValue: 234 (Int). Path: first/second/[2]. Debug description: You cannot do that!

test_decodingError_valueNotFound_nilUnderlyingError

Before

valueNotFound(Swift.String, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "firstName", intValue: nil)], debugDescription: "Description for debugging purposes", underlyingError: nil))

After

DecodingError.valueNotFound: Expected value of type String but found null instead. Path: [0]/firstName. Debug description: Description for debugging purposes

test_decodingError_keyNotFound_nonNilUnderlyingError

Before

keyNotFound(GenericCodingKey(stringValue: "name", intValue: nil), Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "city", intValue: nil)], debugDescription: "Just some info to help you out", underlyingError: Optional(main.GenericError(name: "hey, who turned out the lights?"))))

After

DecodingError.keyNotFound: Key \'name\' not found in keyed decoding container. Path: [0]/address/city. Debug description: Just some info to help you out. Underlying error: GenericError(name: "hey, who turned out the lights?")

test_decodingError_typeMismatch_nilUnderlyingError

Before

typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "city", intValue: nil), GenericCodingKey(stringValue: "birds", intValue: nil), GenericCodingKey(stringValue: "1", intValue: 1), GenericCodingKey(stringValue: "name", intValue: nil)], debugDescription: "This is where the debug description goes", underlyingError: nil))

After

DecodingError.typeMismatch: expected value of type String. Path: [0]/address/city/birds/[1]/name. Debug description: This is where the debug description goes

test_decodingError_dataCorrupted_nonEmptyCodingPath

Before

dataCorrupted(Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "first", intValue: nil), GenericCodingKey(stringValue: "second", intValue: nil), GenericCodingKey(stringValue: "2", intValue: 2)], debugDescription: "There was apparently some data corruption!", underlyingError: Optional(main.GenericError(name: "This data corruption is getting out of hand"))))

After

DecodingError.dataCorrupted: Data was corrupted. Path: first/second/[2]. Debug description: There was apparently some data corruption!. Underlying error: GenericError(name: "This data corruption is getting out of hand")

test_decodingError_valueNotFound_nonNilUnderlyingError

Before

valueNotFound(Swift.Int, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "population", intValue: nil)], debugDescription: "Here is the debug description for value-not-found", underlyingError: Optional(main.GenericError(name: "these aren\\\'t the droids you\\\'re looking for"))))

After

DecodingError.valueNotFound: Expected value of type Int but found null instead. Path: [0]/population. Debug description: Here is the debug description for value-not-found. Underlying error: GenericError(name: "these aren\\\'t the droids you\\\'re looking for")

test_encodingError_invalidValue_emptyCodingPath_nonNilUnderlyingError

Before

invalidValue(345, Swift.EncodingError.Context(codingPath: [], debugDescription: "You cannot do that!", underlyingError: Optional(main.GenericError(name: "You really cannot do that"))))

After

EncodingError.invalidValue: 345 (Int). Debug description: You cannot do that!. Underlying error: GenericError(name: "You really cannot do that")

test_decodingError_typeMismatch_nonNilUnderlyingError

Before

typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "1", intValue: 1), GenericCodingKey(stringValue: "street", intValue: nil)], debugDescription: "Some debug description", underlyingError: Optional(main.GenericError(name: "some generic error goes here"))))

After

DecodingError.typeMismatch: expected value of type String. Path: [0]/address/[1]/street. Debug description: Some debug description. Underlying error: GenericError(name: "some generic error goes here")

test_encodingError_invalidValue_emptyCodingPath_nilUnderlyingError

Before

invalidValue(123, Swift.EncodingError.Context(codingPath: [], debugDescription: "You cannot do that!", underlyingError: nil))

After

EncodingError.invalidValue: 123 (Int). Debug description: You cannot do that!

test_decodingError_keyNotFound_nilUnderlyingError

Before

keyNotFound(GenericCodingKey(stringValue: "name", intValue: nil), Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "city", intValue: nil)], debugDescription: "How would you describe your relationship with your debugger?", underlyingError: nil))

After

DecodingError.keyNotFound: Key \'name\' not found in keyed decoding container. Path: [0]/address/city. Debug description: How would you describe your relationship with your debugger?

test_decodingError_dataCorrupted_emptyCodingPath

Before

dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON", underlyingError: Optional(main.GenericError(name: "just some data corruption"))))

After

DecodingError.dataCorrupted: Data was corrupted. Debug description: The given data was not valid JSON. Underlying error: GenericError(name: "just some data corruption")

test_encodingError_invalidValue_nonEmptyCodingPath_nonNilUnderlyingError

Before

invalidValue(456, Swift.EncodingError.Context(codingPath: [GenericCodingKey(stringValue: "first", intValue: nil), GenericCodingKey(stringValue: "second", intValue: nil), GenericCodingKey(stringValue: "2", intValue: 2)], debugDescription: "You cannot do that!", underlyingError: Optional(main.GenericError(name: "You really cannot do that"))))

After

EncodingError.invalidValue: 456 (Int). Path: first/second/[2]. Debug description: You cannot do that!. Underlying error: GenericError(name: "You really cannot do that")

:bicycle::house: The bike shed is open for business!

6 Likes

. seems like the most common and most format and platform agnostic delimiter. Use of / or \ are less familiar in that context, may be confused with actual file path separators, and may have platform-specific connotations we want to avoid.

4 Likes

. or ; sounds fine to me. If you interpolate any user defined strings before that you can consider surrounding those in '' or [] maybe? This example probably could use the path escaped like EncodingError.invalidValue: '234' (Int). Path: 'first/second/[2]'. Debug description: You cannot do that!

Only thing I was so cautious of in there was that we don't add newlines :slight_smile:

Otherwise all the examples look like quite an improvement -- also happy about prefixing it all with the error enum and case, this looks good and useful, thank you!

I'd prefer to see:

something[0].address.city.birds[1].name

as it would resemble normal swift code and the syntax we use for key paths.

Note the absence of / or . delimiter after "birds".

13 Likes

+1 to the suggestions to use . as the delimiter, as it aligns with string key path syntax in objc. But whatever delimiter is chosen, it’s maybe a good idea to escape any occurrences of it in the keys.

I also agree that your examples present a huge improvement over the existing format. And the order of things you’ve selected seems ideal.

Makes sense. What do you recommend? A backslash, just by convention? Would we then have to worry about backslash-escaping backslashes?

You could use the JS syntax to escape: foo.bar["key.with.dots\"and\"quotes"].baz (which would help with dictionary-style mapping keys).

Or the Swift syntax: foo.bar.`key.with.dots"and"quotes`.baz

Thanks for the suggestions! I've opted for this.kind[3].of.syntax[4][5] in an update to the PR.

For escaping, I've gone with a lightweight approach, combining some suggestions from above:

  • Keys containing periods, backslashes, or backticks are wrapped in backticks
  • Backticks and backslashes are backslash-escaped
["first.second", 3] -> `first.second`[3]
[1, "second`third"] -> [1].`second\`third`
[1, "second\\third"] -> [1].`second\\third`
[1, "two.three\\four`five.six..seven```eight", 9] -> [1].`two.three\\four\`five.six..seven\`\`\`eight`[9]

Please check out the tests and implementation in the PR and let me know if this feels good.

8 Likes

As I mentioned in the review thread, I would much prefer if we embraced existing standards for expressing paths like, the jq dsl for JSON, XPath for XML, etc.

Is it viable to have an extension point on the Decoder protocol for it do specify a custom way to format a key path?

Would it be possible to make an error message that includes the actual JSON, but only show the relevant parts?

[
  {
    // ...
    "home": {
      // ...
      "country": {
        "name": "England"
        // Missing key "population" of type Int
      }
    }
  }
]

Modifying Decoder with a new extension point is not in scope for this proposal; we need to stay focused on formatting the information we have available. But I do think that sounds handy and is probably a good idea to bring up in the design of future Swift serialization tools or a later proposal.

I’m curious if you can say more about specifically what you’d be looking for in an output format? If we could customize the path per serialization format, there are obvious advantages to being able to generate a path that is compatible with tools such as jq. But given that we are currently providing an output format that is serialization-format-agnostic, what advantages do you see to using any one particular syntax? You can always built your own helper functions to format errors in ways that work with jq, plutil, etc. I think, for this proposal, we’re looking for something that’s readable and potentially useful in a Swift context, but it can’t make guarantees about the specific format. That said, I’m not opposed to making small tweaks if it doesn’t hurt readability and it provides a useful shortcut for a common use case.

Unfortunately, that is out of scope of this proposal, since it would require re-parsing the JSON in order to extract the surrounding lines. However, I think we eventually do want to be able to print surrounding context, and I addressed it briefly in the “Future directions” portion of the proposal, including a link to a prototype I wrote a while ago that does something similar.

1 Like

It didn’t occur to me at the time, but is that even technically possible? Can the standard library add new protocol requirements (with default implementations) without breaking ABI?

Literally only that “there are obvious advantages to being able to generate a path that is compatible with tools such as jq”

I don’t much mind if the paths are delimited with . or /, quoted keys or not, etc. Any reasonably readable/aesthetic choice is fine by me.

The most important part for me is the usability of being able to take that path, and have it help me with debugging. If I first have to transform it into some usable form (with sed or whatever), then that’s extra toil

I’m wary to commit to supporting any particular format:

  • It could add additional punctuation that would be at odds with the main goal of this proposal (readability for debugging). I’d prefer not to have newer developers puzzling over extra syntax if it’s avoidable.
  • If we commit to, say, jq format, we’d surely be signing up the maintainers of the stdlib for future bug reports if people find issues where our formatter is out of spec or has an edge case. I’m hoping this PR is a low-maintenance way to make these types more usable in the general case.
  • Which format would we pick? Maybe jq is an obvious pick, but it’s not universal, and Codable is used by many formats that aren’t JSON.

That said, I love the idea of having options for various path formats, and I have a few thoughts:

  1. I encourage you to discuss error formatting in this thread: The future of serialization & deserialization APIs
  2. If you have a particular format that would be useful for debugging in your own code base, don’t wait for the standard library to add it! You have all the information you need in the CodingKey protocol’s public requirements to build your own formatter. Then you could write an extension EncodingError and DecodingError in your own project that would let you run (lldb) po someError.jq to get the jq formatted version, for example.
  3. Building on the above, I could see such a utility being built as a Swift package that allowed others to contribute handy formatting implementations, either to the main code base of the package, or as in-app conformances to a protocol provided by the library.