[Pitch] Add a `skip()` function to `UnkeyedDecodingContainer`

Hi everyone,

There was a similar 2019 pitch from @igorkulman, which received positive feedback but stalled before review. Its evolution PR and implementation PR were closed as stale in 2022.

Summary

Add a skip() function to UnkeyedDecodingContainer with a default implementation.
It moves past the element without decoding it.
For example after a failed decode attempt or simply to ignore elements we don't need.

Motivation

If one element of the array fails to decode, the whole array fails, and usually the whole response with it. Many apps would rather drop the bad element and keep the rest.

For example a feed of items with the following structure:

struct Feed: Decodable {
    let items: [Item]
}

struct Item: Decodable {
    let title: String
    let kind: Kind

    enum Kind: String, Decodable {
        case article, video
    }
}

The server starts sending a new kind the app doesn't know yet:

{
  "items": [
    { "title": "Swift 6.5 released", "kind": "article" },
    { "title": "Episode 42",         "kind": "podcast" },
    { "title": "WWDC recap",         "kind": "video" }
  ]
}

With synthesized conformance, decoding Feed throws, because "podcast"
isn't a valid Item.Kind.

The obvious fix

The natural next step is to decode the items one at a time and ignore
failures:

init(from decoder: any Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    var elements = try container.nestedUnkeyedContainer(forKey: .items)

    var items: [Item] = []
    while !elements.isAtEnd {
        if let item = try? elements.decode(Item.self) {
            items.append(item)
        }
        // "Episode 42" fails, `currentIndex` stays at 1,
        // and the loop retries it forever.
    }
    self.items = items
}

private enum CodingKeys: String, CodingKey { case items }

This compiles and looks correct, but it hangs at runtime.
An unkeyed container's currentIndex is incremented only after a successful decode call. Code can retry a failed element as a different type. But it leaves no way to move past an element that can't be decoded.

The workaround

The widely shared workaround is to decode a placeholder type whose initializer ignores its input, and throw the result away:

private struct SkippedElement: Decodable {
    init(from decoder: any Decoder) {}  // ignores its input
}

while !elements.isAtEnd {
    if let item = try? elements.decode(Item.self) {
        items.append(item)
    } else {
        _ = try elements.decode(SkippedElement.self)  // decode and discard
    }
}

This works with Foundation's decoders, but it has real drawbacks:

  • It isn't discoverable. Developers usually find it in bug reports or blog posts, after first shipping or debugging the infinite loop.
  • It hides intent. "Decode a value and discard it" doesn't read as "skip this element."
  • It relies on behavior the code never states. Whether decoding the placeholder succeeds for a null element depends on the decoder. And decoders don't handle failures consistently: some advance even when a decode throws, which makes this loop silently drop the next, valid element too.
    Forum thread reports that difference between Foundation's
    decode(_:) and decode(_:configuration:)

Proposed solution

Add skip() to UnkeyedDecodingContainer:

var messages: [Message] = []
while !container.isAtEnd {
    if let message = try? container.decode(Message.self) {
        messages.append(message)
    } else {
        try container.skip()
    }
}

skip() moves past exactly one element, whatever it contains, including null values and nested containers.

skip() is a protocol requirement with a default implementation, so it works with existing decoders immediately. Decoders can override it with an implementation suited to their format (for example one that throws if their format doesn't allow skipping).

Detailed design

public protocol UnkeyedDecodingContainer {

    /// Advances past the next element without decoding it.
    ///
    /// A `decode` call that fails doesn't increment `currentIndex`, so the
    /// container stays on the element that couldn't be decoded. Use `skip()`
    /// to move past that element, or past any element you don't need.
    /// On success, `currentIndex` is incremented by one. Skipping doesn't
	/// depend on the element's type, so it works for null values and nested
	/// containers too.
	///
	/// The default implementation works for formats that mark where each value
	/// ends, such as JSON and property lists. A decoder for a format without such
	/// markers should implement this method: it should move past the element if
	/// decoder can determine the element's size, and throw an error if it can't.
	///
	/// - throws: `DecodingError.valueNotFound` if there are no more values to
	/// skip.
    @available(SwiftStdlib 6.5, *)
    mutating func skip() throws
}

extension UnkeyedDecodingContainer {
    @available(SwiftStdlib 6.5, *)
    public mutating func skip() throws {
        guard !isAtEnd else {
            throw DecodingError.valueNotFound(
                Any?.self,
                DecodingError.Context(
                    codingPath: codingPath,
                    debugDescription: "Unkeyed container is at end."))
        }
		// `decode(_:)` may throw `valueNotFound` for a null value without
		// calling the type's initializer, so handle null first.
		if try decodeNil() {
			return
		}
        _ = try decode(_SkippedElement.self)
    }
}

/// A value that decodes successfully from any element without reading it.
internal struct _SkippedElement: Decodable {
    internal init(from decoder: any Decoder) throws {}
}

Semantics

  • On success, skip() moves past exactly one element and increments currentIndex by one.
  • Skipping doesn't depend on the element's type, so skip() doesn't throw DecodingError.typeMismatch.
  • If skip() throws, currentIndex doesn't change, the same as a failed decode call.
  • skip() throws DecodingError.valueNotFound if the container is at its end.
  • skip() throws DecodingError.dataCorrupted if the decoder can't determine where the element ends, for example because the encoded data is malformed, or because the decoder's format doesn't support skipping. Continuing would mean guessing where the next element starts, which could silently produce wrong values.
  • A decoder only needs to find where the element ends. It isn't required to validate the skipped element's contents.
  • Code that ignores errors from skip() with try? must check that currentIndex changed before continuing.

The default implementation relies only on the documented contract that currentIndex is incremented after every successful decode call:

Lines 2733–2735 stdlib/public/core/Codable.swift:

  /// The current decoding index of the container (i.e. the index of the next
  /// element to be decoded.) Incremented after every successful decode call.
  var currentIndex: Int { get }

Source compatibility

N/A

ABI compatibility

Adding a requirement with a default implementation to a protocol is ABI-additive.
Existing conforming binaries keep working and use the default implementation.

Implications on adoption

Protocol requirements can't be back-deployed, so code that supports earlier releases must check availability with if #available and provide its own fallback for older systems, for example by decoding a type whose init(from:) ignores its input.

Future directions

  • Lossy array decoding.
    A convenience that decodes an array while skipping and reporting elements that fail. I'll pitch this separately.

  • skip(count:) to advance past several elements at once.

  • Implementations in Foundation.
    JSONDecoder and PropertyListDecoder could implement skip() by moving directly to the next value, without going through the generic decoding path.

Alternatives considered

N/A

Questions for the community

  1. If you maintain a decoder: is there anything that would make skip() hard to implement efficiently for your format?

Thanks for reading!

2 Likes