Skip to content

Expose extension field numbers as generated Swift constants - #3676

Open
loganblevins wants to merge 1 commit into
square:masterfrom
loganblevins:loganblevins/swift-extension-field-number-constants
Open

Expose extension field numbers as generated Swift constants#3676
loganblevins wants to merge 1 commit into
square:masterfrom
loganblevins:loganblevins/swift-extension-field-number-constants

Conversation

@loganblevins

@loganblevins loganblevins commented Aug 14, 2026

Copy link
Copy Markdown
Member

What

The Swift code generator now emits a public static let fieldNumber_<name>: UInt32 constant alongside every generated extension accessor, mirroring the existing default_<name> constants:

extension FooBar {
    public var ext: FooBar.FooBarBazEnum? {
        get { ... }
        set { ... }
    }
    /**
     * Field number for the ext extension field.
     */
    public static let fieldNumber_ext: UInt32 = 101
}

Why

The generated extension accessors hardcode their decoding behavior: try? ProtoDecoder().decode(...), i.e. the default .throwError enum strategy with the error swallowed. Consumers that need different semantics — a .returnNil decoder so a single unrecognized enum value doesn't nil the entire payload, or an unknownFields[tag] presence check to distinguish an absent extension from an undecodable one — must call parseUnknownField/setUnknownField directly, and those APIs take a raw fieldNumber: UInt32.

Today no generated symbol carries that field number: callers copy the literal out of the .proto source by hand, and nothing keeps the copy in sync with codegen. (GPB exposes the same information via GPBExtensionDescriptor; Wire has no Swift equivalent.) We hit this while migrating envelopes — a proto2 message whose payloads are all extensions — from GPB to Wire: extension field numbers had to be hardcoded and pinned with hand-written tag-agreement tests. With this change the constants come from codegen, so they are contract-checked at the source.

Scope notes:

  • The constant is generated for every extension field, including repeated fields (which get no default_ constant).
  • For heap-allocated (CopyOnWrite) messages the constant is emitted on the extended type only, not again on its Storage type.
  • Naming follows the default_<name> precedent; the type is UInt32 so the constant can be passed straight to parseUnknownField(fieldNumber:).

Testing

  • Two new SwiftGeneratorTest cases: constants for singular and repeated extension fields, and single emission (extended type only) for heap-allocated messages.
  • Goldens regenerated with ./gradlew generateTests (AllTypes, FooBar, SwiftModuleOneMessage).
  • swift test passes locally on macOS.

Suggested CHANGELOG entry (left out of the diff since entries appear to be authored at release time):

  • New: Swift — generated extension accessors are now accompanied by fieldNumber_<name> constants exposing the extension's field number, mirroring the existing default_<name> constants.

🤖 Generated with Claude Code

Wire's Swift codegen generates accessor properties for extension
fields, but never exposes the underlying field number. Consumers that
call parseUnknownField/setUnknownField directly - for example to
supply a custom ProtoDecoder with a different enum decoding strategy,
or to distinguish an absent extension from an undecodable one via an
unknownFields presence check - must hard-code the field number by
cross-referencing the .proto source, and nothing keeps those copies
in sync with codegen. GPB exposes the same information through
GPBExtensionDescriptor; Wire had no Swift equivalent.

Generate a `public static let fieldNumber_<name>: UInt32` constant
alongside each extension accessor, mirroring the existing
`default_<name>` constants. The constant is emitted for every
extension field (including repeated fields, which have no default
constant), on the extended type only - not on its CopyOnWrite
storage type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@loganblevins
loganblevins force-pushed the loganblevins/swift-extension-field-number-constants branch from 74e1c3f to 28e112b Compare August 14, 2026 19:38
@loganblevins
loganblevins marked this pull request as ready for review August 14, 2026 23:58
@loganblevins

Copy link
Copy Markdown
Member Author

@oldergod GH won't let me request reviews... tagging instead

@oldergod oldergod left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving with a few non-blocking comments.

wire-swift-generator/src/main/java/com/squareup/wire/swift/SwiftGenerator.kt:1342, The constant covers only half of what parseUnknownField needs. Signed/fixed integer extensions also require the encoding: argument (see the generated parseUnknownField(fieldNumber: 1003, type: Int32.self, encoding: .signed) in wire-tests-swift/no-manifest/src/main/swift/AllTypes.swift:898), and callers still have to copy that from the .proto by hand, a wrong copy silently decodes wrong instead of failing to compile. Emit the encoding alongside the number so the pair travels together.

wire-swift-generator/src/test/java/com/squareup/wire/swift/SwiftGeneratorTest.kt:93, BigMessage sits exactly on the 16-field heap-allocation threshold, and the test never asserts the storage split actually happened. If the threshold moves or a field is dropped, only one extension block is generated and both assertions pass trivially while the test still reads as covering the guard. Add assertThat(code).contains("public struct Storage") as a precondition.

wire-runtime-swift/src/test/swift/ExtensibleTests.swift:132, The sibling default_ constants have a Swift-side test that reads them off real generated types; the new constants have none. Add a case next to testExtensionDefaultValues that reads the constants off LargeExtensible/Extensible and round-trips one through parseUnknownField/setUnknownField.

wire-swift-generator/src/main/java/com/squareup/wire/swift/SwiftGenerator.kt:1428, The !forStorageType guard is the only thing preventing a duplicate declaration when both emission paths run over the same fields, and that invariant is documented nowhere but a test. Add a one-line comment stating the constant belongs on the extended type and the storage pass must skip it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants