Skip to content

Better debugDescription for EncodingError and DecodingError #80941

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions stdlib/public/core/Codable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,26 @@ extension CodingKey {
public var debugDescription: String {
return description
}

/// A simplified description: the int value, if present, in square brackets.
/// Otherwise, the string value by itself. Used when concatenating coding keys
/// to form a path when printing debug information.
var errorPresentationDescription: String {
if let intValue {
"[\(intValue)]"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm starting to wonder whether we actually need the brackets. It's not like you'll ever be in a situation where you need help differentiating between keys and indices. This isn't PHP, for cryin' out loud. Thoughts on turning [0] into 0?

} else {
stringValue
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like a lone CodingKey ought to have a more complete description than just "[0]" or "keyName". Could this instead be a separate property (probably named similarly to the [any CodingKey] property) that gets called specifically in this context?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps, but an issue is that the description is also called by JSONDecoder in a few cases when providing debug descriptions for decoding error contexts, so it may clobber those too. Unless a) we're fine with that, or b) I'm misunderstanding what you're asking?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a great idea for any external entity (JSONDecoder here) to expect a certain format out of another value's description, even for the purposes of string interpolation unless it's explicitly defined, like one can expect from Int or Double or whatever.

If JSONDecoder wants a certain format when printing CodingKeys in its descriptions, then it should do the work to generate that format itself. At present, that might involve some duplication of code between CodingKey and JSONDecoder / PropertyListDecoder / etc. unless/until that format can be wrapped in some API that does explicitly define the string format.

So, I still think CodingKey's description should remain untouched, and we perhaps follow up with a swift-foundation PR to improve the description construction for the cases you're talking about.

Copy link
Contributor Author

@ZevEisenberg ZevEisenberg Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can make that change if you'd like: removing the changes to the description, and exposing the more concise version via a different variable. Shall I?

Also, just to make sure I understand: is this specifically because you don't want to change the existing implementation in case someone is relying on it? Or do you prefer the implementation of description regardless of concerns around breaking compatibility? Reading comprehension - your intent is clearer than I realized.

}
}

private extension [any CodingKey] {
/// Concatenates the elements of an array of coding keys and joins them with "/" separators to make them read like a path.
var errorPresentationDescription: String {
self
.map { $0.errorPresentationDescription } // Can't use .map(\.errorPresentationDescription) due to https://github.com/swiftlang/swift/issues/80716
.joined(separator: "/")
}
}

//===----------------------------------------------------------------------===//
Expand Down Expand Up @@ -3724,6 +3744,64 @@ public enum DecodingError: Error {
}
}

@available(SwiftStdlib 6.2, *)
extension EncodingError: CustomDebugStringConvertible {
public var debugDescription: String {
let (message, context) = switch self {
case .invalidValue(let value, let context):
(
"Invalid value: \(String(reflecting: value)) (\(type(of: value)))",
context
)
}

var output = """
\(message)
\(context.debugDescription)
"""

if let underlyingError = context.underlyingError {
output.append("\nUnderlying error: \(underlyingError)")
}
if !context.codingPath.isEmpty {
output.append("\nPath: \(context.codingPath.errorPresentationDescription)")
}

return output
}
}

@available(SwiftStdlib 6.2, *)
extension DecodingError: CustomDebugStringConvertible {
public var debugDescription: String {
let (message, context) = switch self {
case .typeMismatch(let expectedType, let context):
("Type mismatch: expected value of type \(expectedType).", context)
case .valueNotFound(let expectedType, let context):
("Expected value of type \(expectedType) but found null instead.", context)
case .keyNotFound(let expectedKey, let context):
("Key '\(expectedKey.errorPresentationDescription)' not found in keyed decoding container.", context)
case .dataCorrupted(let context):
("Data was corrupted.", context)
}

var output = message

if !context.debugDescription.isEmpty {
output.append("\nDebug description: \(context.debugDescription)")
}

if let underlyingError = context.underlyingError {
output.append("\nUnderlying error: \(underlyingError)")
}
if !context.codingPath.isEmpty {
output.append("\nPath: \(context.codingPath.errorPresentationDescription)")
}

return output
}
}

// The following extensions allow for easier error construction.

internal struct _GenericIndexKey: CodingKey, Sendable {
Expand Down
259 changes: 259 additions & 0 deletions test/stdlib/CodableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,237 @@ class TestCodable : TestCodableSuper {
expectRoundTripEqualityThroughPlist(for: UUIDCodingWrapper(uuid), lineNumber: testLine)
}
}

// MARK: - DecodingError
func expectErrorDescription(
_ expectedErrorDescription: String,
fromDecodingError error: DecodingError,
lineNumber: UInt = #line
) {
expectEqual(String(describing: error), expectedErrorDescription, "Unexpectedly wrong error: \(error)", line: lineNumber)
}

func test_decodingError_typeMismatch_nilUnderlyingError() {
expectErrorDescription(
#"""
Type mismatch: expected value of type String.
Debug description: This is where the debug description goes
Path: [0]/address/city/birds/[1]/name
"""#,
fromDecodingError: DecodingError.typeMismatch(
String.self,
DecodingError.Context(
codingPath: [0, "address", "city", "birds", 1, "name"] as [GenericCodingKey],
debugDescription: "This is where the debug description goes"
)
)
)
}

func test_decodingError_typeMismatch_nonNilUnderlyingError() {
expectErrorDescription(
#"""
Type mismatch: expected value of type String.
Debug description: Some debug description
Underlying error: GenericError(name: "some generic error goes here")
Path: [0]/address/[1]/street
"""#,
fromDecodingError: DecodingError.typeMismatch(
String.self,
DecodingError.Context(
codingPath: [0, "address", 1, "street"] as [GenericCodingKey],
debugDescription: "Some debug description",
underlyingError: GenericError(name: "some generic error goes here")
)
)
)
}

func test_decodingError_valueNotFound_nilUnderlyingError() {
expectErrorDescription(
#"""
Expected value of type String but found null instead.
Debug description: Description for debugging purposes
Path: [0]/firstName
"""#,
fromDecodingError: DecodingError.valueNotFound(
String.self,
DecodingError.Context(
codingPath: [0, "firstName"] as [GenericCodingKey],
debugDescription: "Description for debugging purposes"
)
)
)
}

func test_decodingError_valueNotFound_nonNilUnderlyingError() {
expectErrorDescription(
#"""
Expected value of type Int but found null instead.
Debug description: Here is the debug description for value-not-found
Underlying error: GenericError(name: "these aren\'t the droids you\'re looking for")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure where the \' escape sequences are coming from 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, it looks like it might be from String(reflecting:) vs String(describing:). I don't think we want it, but it might be unavoidable if we want to use String(debugDescription:), which seems appropriate given that we're inside a conformance to CustomDebugStringConvertible.

image

Path: [0]/population
"""#,
fromDecodingError: DecodingError.valueNotFound(
Int.self,
DecodingError.Context(
codingPath: [0, "population"] as [GenericCodingKey],
debugDescription: "Here is the debug description for value-not-found",
underlyingError: GenericError(name: "these aren't the droids you're looking for")
)
)
)
}

func test_decodingError_keyNotFound_nilUnderlyingError() {
expectErrorDescription(
#"""
Key 'name' not found in keyed decoding container.
Debug description: How would you describe your relationship with your debugger?
Path: [0]/address/city
"""#,
fromDecodingError: DecodingError.keyNotFound(
GenericCodingKey(stringValue: "name"),
DecodingError.Context(
codingPath: [0, "address", "city"] as [GenericCodingKey],
debugDescription: "How would you describe your relationship with your debugger?"
)
)
)
}

func test_decodingError_keyNotFound_nonNilUnderlyingError() {
expectErrorDescription(
#"""
Key 'name' not found in keyed decoding container.
Debug description: Just some info to help you out
Underlying error: GenericError(name: "hey, who turned out the lights?")
Path: [0]/address/city
"""#,
fromDecodingError: DecodingError.keyNotFound(
GenericCodingKey(stringValue: "name"),
DecodingError.Context(
codingPath: [0, "address", "city"] as [GenericCodingKey],
debugDescription: "Just some info to help you out",
underlyingError: GenericError(name: "hey, who turned out the lights?")
)
)
)
}

func test_decodingError_dataCorrupted_emptyCodingPath() {
expectErrorDescription(
#"""
Data was corrupted.
Debug description: The given data was not valid JSON
Underlying error: GenericError(name: "just some data corruption")
"""#,
fromDecodingError: DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: [] as [GenericCodingKey], // sometimes empty when generated by JSONDecoder
debugDescription: "The given data was not valid JSON",
underlyingError: GenericError(name: "just some data corruption")
)
)
)
}

func test_decodingError_dataCorrupted_nonEmptyCodingPath() {
expectErrorDescription(
#"""
Data was corrupted.
Debug description: There was apparently some data corruption!
Underlying error: GenericError(name: "This data corruption is getting out of hand")
Path: first/second/[2]
"""#,
fromDecodingError: DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: ["first", "second", 2] as [GenericCodingKey],
debugDescription: "There was apparently some data corruption!",
underlyingError: GenericError(name: "This data corruption is getting out of hand")
)
)
)
}

// MARK: - EncodingError
func expectErrorDescription(
_ expectedErrorDescription: String,
fromEncodingError error: EncodingError,
lineNumber: UInt = #line
) {
expectEqual(String(describing: error), expectedErrorDescription, "Unexpectedly wrong error: \(error)", line: lineNumber)
}

func test_encodingError_invalidValue_emptyCodingPath_nilUnderlyingError() {
expectErrorDescription(
#"""
Invalid value: 123 (Int)
You cannot do that!
"""#,
fromEncodingError: EncodingError.invalidValue(
123 as Int,
EncodingError.Context(
codingPath: [] as [GenericCodingKey],
debugDescription: "You cannot do that!"
)
)
)
}

func test_encodingError_invalidValue_nonEmptyCodingPath_nilUnderlyingError() {
expectErrorDescription(
#"""
Invalid value: 234 (Int)
You cannot do that!
Path: first/second/[2]
"""#,
fromEncodingError: EncodingError.invalidValue(
234 as Int,
EncodingError.Context(
codingPath: ["first", "second", 2] as [GenericCodingKey],
debugDescription: "You cannot do that!"
)
)
)
}

func test_encodingError_invalidValue_emptyCodingPath_nonNilUnderlyingError() {
expectErrorDescription(
#"""
Invalid value: 345 (Int)
You cannot do that!
Underlying error: GenericError(name: "You really cannot do that")
"""#,
fromEncodingError: EncodingError.invalidValue(
345 as Int,
EncodingError.Context(
codingPath: [] as [GenericCodingKey],
debugDescription: "You cannot do that!",
underlyingError: GenericError(name: "You really cannot do that")
)
)
)
}

func test_encodingError_invalidValue_nonEmptyCodingPath_nonNilUnderlyingError() {
expectErrorDescription(
#"""
Invalid value: 456 (Int)
You cannot do that!
Underlying error: GenericError(name: "You really cannot do that")
Path: first/second/[2]
"""#,
fromEncodingError: EncodingError.invalidValue(
456 as Int,
EncodingError.Context(
codingPath: ["first", "second", 2] as [GenericCodingKey],
debugDescription: "You cannot do that!",
underlyingError: GenericError(name: "You really cannot do that")
)
)
)
}
}

// MARK: - Helper Types
Expand All @@ -1118,6 +1349,18 @@ struct GenericCodingKey: CodingKey {
}
}

extension GenericCodingKey: ExpressibleByStringLiteral {
init(stringLiteral: String) {
self.init(stringValue: stringLiteral)
}
}

extension GenericCodingKey: ExpressibleByIntegerLiteral {
init(integerLiteral: Int) {
self.init(intValue: integerLiteral)
}
}

struct TopLevelWrapper<T> : Codable, Equatable where T : Codable, T : Equatable {
let value: T

Expand All @@ -1130,6 +1373,10 @@ struct TopLevelWrapper<T> : Codable, Equatable where T : Codable, T : Equatable
}
}

struct GenericError: Error {
let name: String
}

// MARK: - Tests

#if !FOUNDATION_XCTEST
Expand Down Expand Up @@ -1183,6 +1430,18 @@ var tests = [
"test_URL_Plist" : TestCodable.test_URL_Plist,
"test_UUID_JSON" : TestCodable.test_UUID_JSON,
"test_UUID_Plist" : TestCodable.test_UUID_Plist,
"test_decodingError_typeMismatch_nilUnderlyingError" : TestCodable.test_decodingError_typeMismatch_nilUnderlyingError,
"test_decodingError_typeMismatch_nonNilUnderlyingError" : TestCodable.test_decodingError_typeMismatch_nonNilUnderlyingError,
"test_decodingError_valueNotFound_nilUnderlyingError" : TestCodable.test_decodingError_valueNotFound_nilUnderlyingError,
"test_decodingError_valueNotFound_nonNilUnderlyingError" : TestCodable.test_decodingError_valueNotFound_nonNilUnderlyingError,
"test_decodingError_keyNotFound_nilUnderlyingError" : TestCodable.test_decodingError_keyNotFound_nilUnderlyingError,
"test_decodingError_keyNotFound_nonNilUnderlyingError" : TestCodable.test_decodingError_keyNotFound_nonNilUnderlyingError,
"test_decodingError_dataCorrupted_emptyCodingPath" : TestCodable.test_decodingError_dataCorrupted_emptyCodingPath,
"test_decodingError_dataCorrupted_nonEmptyCodingPath" : TestCodable.test_decodingError_dataCorrupted_nonEmptyCodingPath,
"test_encodingError_invalidValue_emptyCodingPath_nilUnderlyingError": TestCodable.test_encodingError_invalidValue_emptyCodingPath_nilUnderlyingError,
"test_encodingError_invalidValue_nonEmptyCodingPath_nilUnderlyingError": TestCodable.test_encodingError_invalidValue_nonEmptyCodingPath_nilUnderlyingError,
"test_encodingError_invalidValue_emptyCodingPath_nonNilUnderlyingError": TestCodable.test_encodingError_invalidValue_emptyCodingPath_nonNilUnderlyingError,
"test_encodingError_invalidValue_nonEmptyCodingPath_nonNilUnderlyingError": TestCodable.test_encodingError_invalidValue_nonEmptyCodingPath_nonNilUnderlyingError,
]

#if os(macOS)
Expand Down