Async Digital / Iris
Iris internals

From URL to canonical intent

The seam

An incoming link is a string. Before the app can act on it, that string has to become a decision the app understands. Iris draws a single seam for that step: one small protocol whose one method turns any URL, a custom-scheme deep link or a Universal Link, into one typed intent. Past the seam the app reasons about the intent and never touches the URL again.

Abstract

A URL is a stringly-typed input. Left unmanaged, every screen that reacts to a link re-inspects the raw string: checks the scheme, matches the host, splits the path, pulls query items, converts pieces to identifiers. That knowledge scatters across the app, and the copies drift.

Iris puts all of it behind one small protocol, URLParsing, whose single method maps a URL to a typed, app-owned intent. The return is total: an unrecognised URL resolves to a fallback intent rather than a failure each caller must handle. This piece explains the seam, why the intent it produces is canonical, and what the design costs.


Problem

A URL is a stringly-typed input

A deep link arrives as a URL, which is a string with structure. To act on one, code has to read that structure: which scheme, which host, which path segments, which query items, and then turn the raw pieces into the app’s own identifiers. A conversation id is not a String; it is the app’s conversation-id type, and something has to do the conversion. Done at the point of use, all of that reading happens everywhere a link can land.

The trouble is duplicated interpretation. A settings screen, a conversation screen, a test, a Shortcut handler each end up string-matching the same URL shapes and re-deriving the same identifiers. Nothing forces them to agree. One site is updated when a path changes and another is missed, and the same URL now means two things depending on which code read it. The URL is an input the whole app depends on, expressed as a string the whole app is free to misread.

The fix is to read the string exactly once, in one place, and hand everything downstream a value it cannot misread. That one place is a codec: a type whose only job is to turn a URL into the app’s own typed intent.


Mechanism

One method, one typed intent

The seam is a protocol called URLParsing. It is deliberately small: a Sendable protocol with an associated Intent type (itself Sendable and Equatable) and exactly one method, parse, which takes a URL and returns an Intent. There is no other surface. Everything a link needs to become an app decision happens inside that single call. A type that conforms to URLParsing is what this series calls a codec.

The input is just a URL. The protocol does not care whether the link arrived as a custom-scheme deep link (botmessages://conversation/aria-7) or as a Universal Link over https: both are URLs, and both meet the same contract. A codec is free to inspect scheme, host and path however it needs. The fiddly work of matching patterns and percent-decoding segments is its private business, and it is the only place in the app that does it.

Fig 1 Two forms of the same link, one parse contract. Whether the URL is a custom scheme or a Universal Link, the same single method turns it into one typed intent.

The return type is the quiet decision. parse returns an Intent, not an optional. There is no “parse failed” for the caller to branch on. An unrecognised or malformed URL still yields an intent: a fallback the codec chooses. Downstream code receives a typed value every single time and never has to reason about a URL that did not parse.

Iris ships the parts to build such a codec from a table rather than a hand-written switch. A URLRoute is one rule for one URL shape; a URLPathRouter walks an ordered list of routes and returns the first intent that matches, or nothing when none do. That router is partial by design, so it is honest about a miss. A codec built on it maps that “nothing” to its fallback, which is how the router stays partial while the codec stays total. Where a matched route hands back captured path and query values as raw strings, the codec converts them into the app’s own identifiers (Tagged, RawRepresentable, whatever the app uses) right there. Strings become typed exactly once, at the boundary.


Guarantees

Total, typed, canonical

Three properties earn the indirection, and each one removes a class of mistake the scattered version left open.

Total. Because parse never returns nothing, no call site has to handle “the URL did not parse”. The unknown case is a real intent the app decided on once, in the codec, not an error path re-implemented at every reader. There is one typed value to switch over, always.

Typed. The Intent is the app’s own type, constrained to be Sendable and Equatable. Sendable lets the intent cross isolation boundaries, so it can be handed to an actor or a task without opening a data race. Equatable lets the app compare two intents: deduplicate a repeat, assert an expected one in a test, decide whether a new intent differs from the last. None of that is available on a raw URL string without parsing it again.

Canonical. Iris keeps the two directions of a URL rule in one place: the same URLRoute that parses a URL into an intent also emits the URL for that intent, and the router can run either way. Because the pair lives in one value, the URL you would build for an intent and the intent you would parse from that URL cannot drift apart silently. The intent is the canonical form; a URL is one serialisation of it, defined once, both ways.

Fig 2 The URL ends at the seam. Iris’s Broadcaster parses the incoming URL once and forwards a Baton<Intent>; every subscriber past it sees the typed baton, never the string.

The payoff shows up wherever the intent travels. Iris’s Broadcaster is generic over any type conforming to URLParsing: it parses an incoming URL once, wraps the resulting intent in a Baton (the intent together with its flow correlation context), and multicasts that baton to every active subscriber, replaying the last one to any subscriber that joins later. Past the broadcaster, the URL is gone. Subscribers receive Baton<Intent> values and reason about the intent. The string stopped at the seam.

The URL is read once, at the door. Everything past it reasons about a typed intent, never a string it could read a second, differing way.


Cost

What the seam costs

The indirection is not free, and it is worth naming the bill rather than pretending the pattern is all upside.

What the design pays for

You have to design an intent type. The app needs one typed vocabulary of everything a link can mean, and it has to be kept in step with the destinations that actually exist. A codec is only ever as good as the intent it targets, and that intent is now a real artefact to maintain, not an accident of whatever string a handler happened to match.

The codec is the one place that must be correct. All the URL-matching risk is concentrated there instead of spread thin, which is the point, but it means the codec earns careful tests: the routes it matches, the identifiers it converts captures into, and the fallback it lands on. The upside of one place to get wrong is that there is only one place to get right.

A total parse hides the miss. Because an unrecognised URL resolves to a fallback rather than an error, a route you forgot to add does not crash; it quietly resolves to the fallback intent. The safety net is deliberate, but it makes the fallback a real case to handle and to test, not a corner to ignore.

Read the string once and the rest of the app gets a value it can trust. The typed intent this seam produces is what the state-aware resolver and the tap-and-URL resolver go on to route, and what the broadcaster hands to every subscriber. The wider map of how a link is resolved, delivered, and applied lives on the deep-linking hub.