Bringing macOS to the Go Ecosystem
tmc/apple generates cgo-free Go bindings for 150-odd Apple frameworks. The hard parts are the places where Objective-C’s type system says things Go’s refuses to.
tmc/apple is a set of Go bindings for Apple’s frameworks — AppKit, Foundation, AVFoundation, Metal, 150-odd others — generated from Apple’s own documentation data and SDK headers, with no cgo anywhere. Every method call bottoms out in purego invoking objc_msgSend on raw pointers. At runtime there are no types at all, just selectors and object addresses.
Jeff Lindsay’s DarwinKit (né macdriver) proved that Apple’s documentation data could drive Go binding generation at framework scale. tmc/apple pushes the same idea along two axes: a cgo-free runtime, and a generator whose type-mapping decisions are machine-checked rather than trusted.
Every type in the generated API is a claim the generator chose to make. The message-send plumbing is settled; the work is deciding what static story to tell in Go about a dynamically typed API, and what to do where the two languages disagree.
The translations that fall out cleanly
Most of the mapping is unsurprising.
A class becomes a struct wrapping an object ID. Inheritance becomes struct embedding: NSTextField embeds NSControl embeds NSView, and Go’s method promotion gives you the inherited surface for free. For every class there’s an I-interface (INSView) so functions can accept “an NSView or anything descended from one,” which is the closest Go gets to a class hierarchy. Protocols become interfaces, plus a concrete wrapper type for the other direction: when the framework hands you back some object that conforms to a protocol, you wrap the ID and get typed methods on it.
One early decision mattered more than it looked. Objective-C’s init family became package-level constructor functions — NewMutableStringWithContentsOfFile(path) — rather than methods, for ergonomic reasons. The consequence, visible only later, is that constructors don’t participate in interface satisfaction, so an entire category of type-system conflict structurally can’t arise for them.
id, and the covariance problem
Objective-C has a type called id: some object, any object. And it has a convention Go has no equivalent for: a subclass, or a more specific protocol, may redeclare an inherited selector at a narrower type. Covariant returns. The SDK does this deliberately and repeatedly. In AppKit’s accessibility headers, one selector, accessibilityValue, is declared at seven different types across sibling protocols:
NSAccessibilitySwitch - (nullable NSString *) accessibilityValue
NSAccessibilityRadioButton - (nullable NSNumber *) accessibilityValue
NSAccessibilityCheckBox - (nullable NSNumber *) accessibilityValue
NSAccessibilityStaticText - (nullable NSString *) accessibilityValue
NSAccessibilityProgressIndicator - (nullable NSNumber *) accessibilityValue
NSAccessibilityStepper - (nullable id) accessibilityValue
NSAccessibilitySlider - (nullable id) accessibilityValue
while the base protocol every view adopts declares it as plain id. This is legal in Objective-C, because NSString * and NSNumber * are both id underneath; the narrowing costs nothing at runtime and tells the reader something true.
Go cannot say this. A Go method set is a set of exact signatures. One concrete type gets one method per name, and every interface naming that method must agree with it exactly. So for NSTextField, which adopts both the base protocol and the static-text role, the generated code faces a three-way impossibility:
func (t NSTextField) AccessibilityValue() string // pick one type...
type NSAccessibilityStaticText interface {
AccessibilityValue() string // ...satisfy this...
}
type NSAccessibilityProtocol interface {
AccessibilityValue() objectivec.IObject // ...and lose this. Or vice versa.
}
No rendering choice fixes it. It’s not a generator bug; it’s a sentence one type system can say and the other can’t. The only question is which retreat to make.
The options, and the one we took
- Drop the member from the role protocols. But for
NSAccessibilityStaticTextthat member essentially is the protocol, so classes would conform to a hollow shell. - Put the narrow type on the class and let the base conformance fail. This matches Apple’s headers, but leaves a machine-verified conformance suite permanently red, with something silently deciding per class which protocol wins.
- Invent second method names for the typed versions. No source-derived name exists, and a bindings generator inventing public API names means maintaining a naming convention forever.
Generic methods, shipped in Go 1.27, don’t help. The release notes are explicit that interface methods may not declare type parameters and generic methods cannot implement interface methods — excluded in both directions, for the same reason the feature was blocked for four years, since dispatching a generic method through an interface would need runtime instantiation. Making the covariant member generic would satisfy no interface at all, converting a handful of blocked conformance pairs into universal non-conformance.
What we landed on: every covariant selector gets the Go mapping of its base declaration, everywhere. Classes, class interfaces, protocol interfaces, wrapper objects, all agreeing. “Base declaration” is not a synonym for “the escape-hatch type”:
accessibilityValue’s base declaration is bareid→objectivec.IObject. Apple’s base contract there genuinely is dynamic: strings for text roles, numbers for value roles.- GameController’s
capturedeclaresid<GCDevicePhysicalInputState>→ that generated protocol interface, which has methods and loses nothing worth keeping. - Its sibling
nextInputStatedeclaresid<A, B>, a protocol composition → a Go interface embedding both. Writing the rule down flushed out a mapping gap: the generator had been quietly collapsing compositions toIObject.
The narrowing that the type system can no longer express moves into generated doc comments — generated from the same parsed SDK declarations, so they can’t rot independently of the code — plus an explicit conversion at the call site when you want the narrow type back. The information is demoted from compiler-enforced to human-directed, not destroyed.
The price: nothing in the API returns string for a text field’s accessibility value anymore, and Apple’s own headers do put NSString * on the role. The bindings are less Apple-shaped than they could be, in exchange for conformance a compiler can check. One structural fact softens it. The covariant residue in the SDK is getter-only: the role protocols narrow returns and never parameters, and the setters all live on the base at id, which is where parameter soundness wants them. Widening returns loses less when the setters already agree.
Once every declaration of a selector agrees by construction, the generator’s conflict-resolution machinery, which used to pick winners silently, has nothing left to resolve. It gets repurposed as an enforcement check that fails loudly if two reachable declarations ever disagree again. The policy becomes an invariant.
Where the analysis was wrong
Before choosing that rule, we normalized the SDK headers, traced the inheritance and adoption edges, and produced a census: eight selectors, nineteen narrowing relations, four frameworks. Structural analysis, done carefully, twice reviewed.
Then we generated actual conformance assertions and handed them to the compiler, with the buckets declared in advance. Of nineteen predicted relations:
- seven actually collide in compiled Go
- five were never conflicts at all — the generator doesn’t redeclare a narrowed member on the child interface, so the child inherits the parent’s signature and everything satisfies
- seven couldn’t be checked, because the framework in question had an unrelated compile-breaking defect
The prediction we’d written down, recorded specifically so it could be wrong, was that AppKit accessibility would be nearly the whole problem. It’s three pairs of seven. A framework nobody was thinking about supplied the majority.
Headers, documentation data, and your own reasoning about both are all predictions. The compiler is the measurement. Write the prediction down first: if we had recorded only the final number, the wrong estimate and the right count would be indistinguishable in the record.
The filter in the generator
While closing this out, an audit turned up a helper called methodReturnsID, sitting in the generator for years, whose doc comment explains that protocol methods with id types can’t go into Go interfaces “because Go doesn’t support covariant/contravariant types.”
A past version of the generator had already met the covariance problem and solved it by silently deleting every id-typed member from every generated protocol interface. Return id? Dropped from the interface. Take an id parameter? Dropped. Take an NSArray * parameter? Also dropped, which isn’t covariance at all but a slice-mapping dodge.
The damage scales. In AppKit and Foundation alone, about twenty id-returning getters are missing from their interfaces, against seven actual covariant pairs in the entire compiled census. The parameter clause is worse, on the order of two hundred members, because dropping methods that accept id defends against a conflict that cannot occur: parameters are the position where widening is sound, and the SDK’s covariant residue is getter-only anyway. NSStandardKeyBindingResponding, the protocol that defines text-editing actions, is the clearest casualty. Every Objective-C action method takes (id)sender. The filter ate all of them — MoveUp, InsertNewline, SelectAll, Yank, roughly a hundred members — leaving an interface that certifies almost nothing of what the protocol means.
A missing interface member is worse than a wrong type. A wrong type fails at compile time with a line number. A missing member fails invisibly: every conformance check involving that protocol goes green because the hard part was removed before checking.
The fix has an ordering constraint. You cannot just restore the members, because restoring them before canonicalizing the classes turns working code red. Canonicalize first, then delete the filter, then let the enforcement invariant stand guard.
What generalizes
Constructors instead of init methods dissolved one conflict class by accident; canonicalizing to the base declaration dissolved another on purpose. Both worked the same way: they moved the disagreement to a place where one language doesn’t have to say the thing the other can’t. Prefer retreats with that shape.
Three habits earned their keep. Make the rule an invariant the generator enforces, not a convention a document describes. Treat every structural analysis as a prediction and write it down before the compiler grades it. And audit what your interfaces don’t say — the most confident-looking checkmark in the suite may be certifying a surface that something quietly emptied years ago.