Most hybrid iOS apps end up with a WKScriptMessageHandler somewhere, listening for stringly-typed JSON blobs posted from JavaScript. It works. It’s also where bugs go to hide: a renamed property on the web side breaks the app at runtime, far away from the change that caused it.

In one hybrid app I worked on, the native shell wrapped a WKWebView that hosted most of the product. The two sides needed to talk constantly: native reading state, native triggering actions, web calling back into native. The number of message types grew over time, and every new one added a small amount of risk that the two sides would drift.

The fix we landed on was to move the contract upstream of either platform.

One contract, two generated bindings

A single TypeScript file in a shared repo describes every message type: fields, direction, return value. From that file, a small generator emits two things:

  • a Swift module the iOS app imports: structs, enums, an async bridge protocol;
  • a TypeScript module the web side imports: typed wrappers around window.webkit.messageHandlers.

The Swift side and the web side now have the same compiler yelling at them when a field is renamed. The contract file is the only place where a message type is defined. The generator runs in the contract repo and cuts versioned releases of both bindings; the iOS and web projects pull those in as dependencies.

What it looks like

A simplified contract entry, sketched for a fictional music-player app:

// contract.ts: the source of truth
type TrackInfo = {
  durationMs: number
  bitrateKbps: number
}

// native → web, awaited
type FromApp = {
  getCurrentTrack(): Promise<TrackInfo>
  setShuffle(enabled: boolean): Promise<void>
}

// web → native, mostly fire-and-forget
type FromWeb = {
  openMiniPlayer(): void
  showQueueScreen(): void
}

The generated Swift side is roughly:

// Generated. Do not edit.
struct TrackInfo: Codable {
  let durationMs: Double
  let bitrateKbps: Double
}

protocol WebBridging {
  func getCurrentTrack() async throws -> TrackInfo
  func setShuffle(enabled: Bool) async throws
  // … one entry per call in FromApp
}

And the call site:

let track = try await bridge.getCurrentTrack()
miniPlayer.update(duration: Int(track.durationMs))

No string keys. No as? [String: Any]. When a field is renamed, the next contract release reflects it. Pulling that release into the iOS project breaks the build exactly where the contract changed.

The runtime is small on purpose

Every generated method funnels into one async function that takes a JS snippet and a Codable return type:

func call<T: Decodable>(
  jsCode: String,
  arguments: [String: Any?],
  returning: T.Type
) async throws -> T

That function serialises the arguments, awaits WKWebView.callAsyncJavaScript, decodes the result, and surfaces any structured error the web side returned. The generated wrappers do the type-specific bits; the runtime stays under a hundred lines.

For web→native calls there’s a handler object per message, each one small by design. The contract types do the work.

What it bought us

A few concrete wins:

Compile-time renames. Bumping the contract in the iOS project surfaced every call site that needed updating.

Reviewable versioning. The generated file has a date in its header. PRs that touch the contract show up in git diff as a single change you can read.

Cheap tests. A Codable payload is a Codable payload. Round-tripping it through JSONEncoder covers the wire format for free, no fixture files needed.

Trade-offs worth naming

The pattern isn’t free. You need a generator, and the generator is one more thing to maintain when TypeScript evolves. You need a release process. Bumping the contract in three repos at once gets ceremonial if you don’t think about it.