All posts

Building a Type-Safe Server-Driven UI Framework in TypeScript

The architectural decisions behind MACHBANK's SDUI framework — factory functions over classes, discriminated unions for compile-time safety, header-based protocol versioning, and the TypeScript trick that made it all type-safe.

In 2025 we built a Server-Driven UI (SDUI) framework at MACHBANK that let us ship product changes to iOS, Android, and Web without mobile deployments. This post is about the architectural decisions that made it work — and a few that we got wrong the first time.

The Core Idea

Server-Driven UI shifts the question “what should the screen look like?” from the client to the server. Instead of the client hardcoding screens and asking the server for data to fill them, the server sends a complete description of the UI — which components to show, in what order, with what content and actions — and the client is a generic renderer with no product logic.

The practical result: a product change that used to require a client release now requires a BFF deployment. Going from a 4-6 week cycle to a same-day deploy.

We built this as two npm packages: mach-sdui-lib (the component library BFF services use to compose UI) and mach-bff-sdk (the Fastify infrastructure layer that serves it). This post covers the decisions made in both.

Decision 1: Factory Functions, Not Classes

The first choice was how to model UI components in TypeScript. The obvious path is classes:

// The intuitive but wrong approach
const button = new Button({ text: 'Pay', action: new NavigateAction(...) })
button.serialize() // → JSON

We chose factory functions instead:

// What we built
const button = Components.Button({ text: 'Pay', action: Actions.NavigateRemote(...) })
// button is already plain JSON — no .serialize() needed

Why it matters: Factory functions return plain objects. Plain objects are JSON-serializable by default, equality-checkable without custom equals() methods, and can be spread, merged, and tested without ceremony. There’s no prototype chain, no instanceof checks, no deserialization step.

When the server builds an SDUI response, it calls JSON.stringify(build({ screens, state })) and sends it. The client parses it with JSON.parse. No custom serializer needed anywhere in the pipeline.

The type safety comes from the return type of each factory function, not from class inheritance. Components.Button() returns IButton, which carries all the shape constraints TypeScript needs to validate the structure — without a single class.

Decision 2: The Build Function as a Validation Gate

The build() function is the final step before a BFF response is serialized. Developers compose their screens using factory functions, then call:

const response = build({
  version: '1.0.0',
  screens: [paymentScreen, receiptScreen],
  state: { amount: 15000 },
})

The build function does three things:

1. Auto-extracts sections from layouts. Sections are the named containers that hold components. In early versions, developers had to pass a separate sections array to build(). This was error-prone — it was easy to forget a section or duplicate one. In v0.1.0 we removed the sections parameter entirely. build() now traverses every layout placement in every screen and collects sections automatically. The developer never manages the sections list manually.

2. Asserts unique section IDs. If two sections share the same ID anywhere in the response, build() throws synchronously with a descriptive error. This is a local crash — not a client-side crash in production.

3. Cross-validates references. Every section ID referenced in a screen must exist in the collected sections, and every collected section must be referenced by at least one screen. Orphaned sections and missing references are both caught at build time.

This turns what would be obscure rendering bugs on mobile into loud, immediate errors during local development.

Decision 3: Discriminated Unions for Compile-Time Enforcement

Some actions have subtly different requirements depending on their state. The AuthorizationModule side-effect is the clearest example. When authorization is required, three additional fields become mandatory: authorizationId, type, and authorizationDisplayInfo. When it’s not required, those fields shouldn’t exist.

A naive approach uses optional fields:

// The wrong way — compiles fine even when required=true and authorizationId is missing
interface AuthorizationData {
  required: boolean
  authorizationId?: string  // optional — TypeScript won't catch missing value
  type?: string
  authorizationDisplayInfo?: object
}

We used a discriminated union instead:

type AuthorizationModuleData =
  | { required: false }
  | {
      required: true
      authorizationId: string      // All three fields are now required
      type: string                 // when required=true
      authorizationDisplayInfo: { title: string; description: string; ... }
    }

Now TypeScript enforces correctness at the call site:

// ✅ Compiles fine
SideEffects.AuthorizationModule({ payload: { data: { required: false } } })

// ✅ Compiles fine
SideEffects.AuthorizationModule({ payload: { data: {
  required: true,
  authorizationId: 'abc-123',
  type: 'BIOMETRIC',
  authorizationDisplayInfo: { ... },
} } })

// ❌ TypeScript error: Property 'authorizationId' is missing
SideEffects.AuthorizationModule({ payload: { data: { required: true } } })

The client never receives a malformed authorization request. The TypeScript compiler is the guard, not runtime validation.

Decision 4: State Refs as Literal Types

SDUI responses often need to reference dynamic values — a payment amount, a card number, a user’s name. We needed a way to express “this field will be filled in by the client at render time” in a way that TypeScript could validate.

The solution was a literal type alias:

type IStateRef = `{state.${string}}`

Combined with a State() helper:

const { state, stateRefs } = State({ amount: 15000, currency: 'CLP' })
// state     = { amount: 15000, currency: 'CLP' }
// stateRefs = { amount: '{state.amount}', currency: '{state.currency}' }

stateRefs.amount is typed as IStateRef, not string. Component props that accept dynamic content accept string | IStateRef. This means you can use stateRefs anywhere a component field expects text, and TypeScript will reject a random string like "state.amount" (no braces) because it doesn’t match the literal pattern.

The client interpolates {state.key} at render time using the state object from the response. No runtime schema; the pattern is the contract.

Decision 5: Header-Based Protocol Versioning

As the framework evolved, breaking changes became inevitable — new required fields, renamed action types, structural changes to layouts. We needed a versioning strategy that didn’t couple client updates to server deployments.

The answer was X-SDUI-Protocol-Min and X-SDUI-Protocol-Max headers.

Clients declare what version range they support. The server selects the richest (highest) version within that range and injects it into the response:

Client: X-SDUI-Protocol-Min: 1.0 / X-SDUI-Protocol-Max: 2.0
Server supports: [1.0, 2.0, 3.0]
Negotiated: 2.0

Client: X-SDUI-Protocol-Min: 1.0 / X-SDUI-Protocol-Max: 3.0
Negotiated: 3.0

If the client’s range doesn’t overlap with any supported version, the server returns 406 Not Acceptable.

The endpoint handler map makes version support explicit:

defineRoutes({
  'GET_/payments/receipt': {
    handler: {
      '1.0': receiptPresenterV1,
      '2.0': receiptPresenterV2,  // V2 with new layout structure
    },
    schemas: { input: {} },
  },
})

This lets us ship breaking changes to the protocol and serve both old and new clients simultaneously, with no routing complexity. Old app versions continue using 1.0 handlers. New versions get 2.0. Both are in production at the same time. When old client adoption drops below threshold, we drop the 1.0 handler.

The TypeScript Trick: HandlerFn with never

Handler functions in the SDK are typed:

export type HandlerFn = (args: never) => Promise<unknown>

The never parameter looks wrong. But it’s deliberate — it exploits TypeScript’s function parameter contravariance.

In TypeScript, a function (args: SpecificType) => R is assignable to (args: never) => R because never is the bottom type: any type is a supertype of never, so a function that handles a specific type can handle never (which has no values).

The practical effect:

// Your presenter function is typed:
async function receiptPresenter(args: { documentNumber: string; machId: string }): Promise<ISduiResponse> { ... }

// You assign it to a HandlerMap:
const handler: HandlerMap = { '1.0': receiptPresenter } // ✅ No cast needed

// Inside register-endpoints.ts, the actual call:
handler[chosenVersion](args as never) // One `as never` cast, in one place

Without never, every handler assignment would need as unknown as HandlerFn, scattering unsafe casts throughout consumer code. With never, the single unsafe cast is inside the SDK, and all consumer code is clean.

What We Got Wrong

Schema versioning was an afterthought. We didn’t design the version negotiation system until we hit our first breaking change. The first month of the framework operated without versioning — every client got the same response format. When we needed to change a required field, we had to coordinate a simultaneous mobile and backend deploy. After that, we built the protocol versioning system. It should have been day one infrastructure.

Section auto-extraction came too late. The original build() API required passing a sections array manually. This caused bugs in every BFF service that used the library — developers would add a new section to a layout and forget to add it to the sections array, which caused silent rendering failures on mobile. Removing the parameter in v0.1.0 was the right call, but we should have designed it this way from the start.

Documentation lag. As the component library grew to 26 components and 13 action types, BFF engineers needed docs to know what each component does and what props it accepts. We built inline TypeScript JSDoc eventually, but the period between “library ships” and “docs exist” generated a lot of Slack questions. Schema documentation should ship alongside the code.

The Result

The framework let MACHBANK’s product team ship UI changes by changing a server response, with no mobile releases and no deployment coordination. The framework won the EPIC Project Award — but the more meaningful outcome was what it did to the team’s speed.

Shipping UI to production is now a backend operation. That’s the whole point.