How We Built a Typed DSL Code Generator for Server-Driven UI

Published: March 19, 2026 18 min read Advanced

Every SDUI backend we've seen has the same dirty secret: somewhere in the codebase, there's a function that builds JSON by hand. It's stringly-typed. There's no autocomplete. The only thing standing between you and a runtime crash is a prayer and maybe a unit test someone wrote six months ago.

We got tired of it. So we built a code generator that reads component schemas from the mobile SDK and spits out a fully typed Kotlin DSL. No more magic strings. No more "wait, is it textStyle or style?" Full IDE autocomplete, compile-time validation, and generated code that's actually readable.

Here's how we did it — and the trade-offs we made along the way.

What We'll Cover

  1. The Problem with Hand-Written SDUI JSON
  2. The Solution: Generated Typed DSL
  3. Schema as Source of Truth
  4. The Code Generator Architecture
  5. The Generated DSL in Action
  6. BYOC: Bring Your Own Components
  7. Handling Edge Cases
  8. Trade-offs We Encountered
  9. Wire Format: DSL → JSON

The Problem with Hand-Written SDUI JSON

If you've built a server-driven UI system, you know the pattern. Your backend defines screens as JSON, the mobile client interprets and renders them. It's a great architecture — Airbnb, Lyft, and Netflix all use it — but the backend authoring experience is terrible.

Here's what a typical SDUI endpoint looks like in practice:

HomeScreenController.kt — the painful version
fun buildHomeScreen(user: User): JsonObject {
    return buildJsonObject {
        put("type", "column")
        put("padding", 16)
        putJsonArray("children") {
            addJsonObject {
                put("type", "text")
                put("content", "Welcome back, ${user.name}!")
                put("style", "headline")  // Is it "headline" or "HEADLINE"?
            }
            addJsonObject {
                put("type", "button")
                put("label", "Get Started")
                putJsonObject("action") {
                    put("type", "navigate")
                    put("destination", "/onboarding")
                }
            }
            addJsonObject {
                put("type", "product_card")  // or is it "productCard"?
                put("title", "Premium Plan")
                put("prce", "$9.99/mo")   // typo. Good luck finding this.
                put("badge", "recommended")
            }
        }
    }
}

Count the problems: magic strings for component types, no validation that "prce" isn't a real prop, no way to know if "headline" should be uppercase, and zero IDE help. You're flying blind. The only feedback loop is "deploy, open the app, see if it crashes."

This gets exponentially worse as your component library grows. With 50+ components, each with their own props, enums, and actions, you're maintaining a parallel type system in strings. It's the SDUI architecture pattern at its worst.

The Solution: Generated Typed DSL

What if instead of writing JSON by hand, you had a Kotlin DSL that knew every component, every prop, every valid enum value — and it was all generated automatically from the mobile SDK's component schemas?

HomeScreenController.kt — the generated DSL version
fun buildHomeScreen(user: User): ScreenDefinition {
    return screen("home") {
        column(padding = 16.dp) {
            text(
                content = "Welcome back, ${user.name}!",
                style = TextStyle.HEADLINE   // enum — no guessing
            )
            button(label = "Get Started") {
                onTap { navigate("/onboarding") }
            }
            productCard(
                title = "Premium Plan",
                price = "$9.99/mo",       // typo? Compiler says no.
                badge = Badge.RECOMMENDED
            )
        }
    }
}

Same screen. But now productCard is a real function with typed parameters. Badge.RECOMMENDED is a generated enum. If you typo price as prce, the compiler catches it. If you try to put a text inside a non-container component, you get a compile error. Your IDE autocompletes everything.

Key insight: The mobile SDK already defines exactly which components exist and what props they accept. The schema is right there. We just needed to read it and generate the backend DSL from it.

Schema as Source of Truth

Every component in a Pyramid app is registered with a schema. When you build your mobile app, the SDK extracts a ComponentSchemaRoot — a complete manifest of every component, model, enum, and action your app supports.

Here's the schema format (TypeScript notation for readability):

Schema Types
interface ComponentSchemaRoot {
    components: Component[];
    models: Model[];
    actions: Action[];
}

interface Component {
    name: string;
    description?: string;
    category?: string;
    container?: boolean;
    allowedChildren?: string[];
    props?: Prop[];
    actions?: Action[];
    slots?: Slot[];
}

interface Prop {
    name: string;
    type: string;       // "string" | "int" | "boolean" | "enum:BadgeType" | "model:Product"
    required: boolean;
    default?: any;
    deprecated?: boolean;
    deprecationMessage?: string;
}

interface Slot {
    name: string;
    description?: string;
    allowedChildren?: string[];
}

A real component schema entry looks like this:

ProductCard schema (extracted from SDK)
{
  "name": "ProductCard",
  "description": "Displays a product with image, title, price, and optional badge",
  "category": "commerce",
  "container": false,
  "props": [
    { "name": "title", "type": "string", "required": true },
    { "name": "price", "type": "string", "required": true },
    { "name": "imageUrl", "type": "string", "required": false },
    { "name": "badge", "type": "enum:Badge", "required": false },
    { "name": "subtitle", "type": "string", "required": false, "deprecated": true,
      "deprecationMessage": "Use description instead" }
  ],
  "actions": [
    { "name": "onTap", "description": "Triggered when card is tapped" }
  ]
}

This is the contract. The mobile app says "I understand these components with these props." The code generator reads this and produces a backend DSL that's guaranteed to match. If the mobile team adds a new component or deprecates a prop, the next codegen run updates the DSL, and the backend developer sees the change immediately — as a compile error if something broke, or as a new autocomplete option if something was added.

The Code Generator Architecture

The generator is conceptually simple: read JSON schema, emit Kotlin source files. In practice, there are a lot of decisions about how to map schema concepts to idiomatic Kotlin.

Input and Output

Input: A ComponentSchemaRoot JSON file containing the full component manifest — components, models, and actions.

Output: Four Kotlin source files:

Let's walk through each.

Generating Component Extension Functions

Each component becomes an extension function on a ContainerScope. Container components (like Column, Row, Card) get a trailing lambda for children. Non-container components don't.

Generator logic (simplified)
fun generateComponentFunction(component: Component): String {
    val fnName = component.name.toCamelCase()
    val params = component.props?.map { prop ->
        val type = mapType(prop.type)
        val default = if (!prop.required) " = null" else ""
        val annotation = if (prop.deprecated == true)
            """@Deprecated("${prop.deprecationMessage}") """
        else ""
        "${annotation}${prop.name}: ${type}${default}"
    } ?: emptyList()

    val childrenParam = if (component.container == true)
        ",\n    children: ContainerScope.() -> Unit = {}"
    else ""

    return """
    fun ContainerScope.${fnName}(
        ${params.joinToString(",\n        ")}${childrenParam}
    ) {
        addComponent("${component.name}", buildProps {
            ${component.props?.joinToString("\n            ") { 
                """prop("${it.name}", ${it.name})""" 
            }}
        }${if (component.container == true) ", children" else ""})
    }
    """
}

For our ProductCard schema, this generates:

Generated — Components.kt (excerpt)
/**
 * Displays a product with image, title, price, and optional badge
 */
fun ContainerScope.productCard(
    title: String,
    price: String,
    imageUrl: String? = null,
    badge: Badge? = null,
    @Deprecated("Use description instead")
    subtitle: String? = null,
    actions: ActionScope.() -> Unit = {}
) {
    addComponent("ProductCard", buildProps {
        prop("title", title)
        prop("price", price)
        prop("imageUrl", imageUrl)
        prop("badge", badge)
        prop("subtitle", subtitle)
    }, buildActions(actions))
}

Notice: required props (title, price) have no default. Optional props default to null. The deprecated subtitle prop carries the @Deprecated annotation with the message from the schema. The IDE will show a strikethrough and the migration hint.

Generating Models as Data Classes

When a prop type references a model (e.g., "type": "model:Product"), the generator creates a Kotlin data class:

Generated — Models.kt (excerpt)
/**
 * Product information for commerce components
 */
data class Product(
    val id: String,
    val name: String,
    val price: Double,
    val currency: String = "USD",
    val imageUrl: String? = null,
    val inStock: Boolean = true
) : PyramidModel {
    override fun toProps(): Map<String, Any?> = mapOf(
        "id" to id,
        "name" to name,
        "price" to price,
        "currency" to currency,
        "imageUrl" to imageUrl,
        "inStock" to inStock
    )
}

Extracting Enums from Prop Types

When the generator encounters "type": "enum:Badge", it looks up the enum definition in the schema and generates a Kotlin enum. The type format enum:EnumName acts as a reference into the models section:

Generated — Enums.kt (excerpt)
enum class Badge(val wireValue: String) {
    RECOMMENDED("recommended"),
    NEW("new"),
    SALE("sale"),
    LIMITED("limited");

    companion object {
        fun fromWire(value: String): Badge? =
            entries.find { it.wireValue == value }
    }
}

enum class TextStyle(val wireValue: String) {
    HEADLINE("headline"),
    TITLE("title"),
    BODY("body"),
    CAPTION("caption"),
    OVERLINE("overline");

    companion object {
        fun fromWire(value: String): TextStyle? =
            entries.find { it.wireValue == value }
    }
}

Each enum carries its wire value — the string that actually appears in JSON. Kotlin convention (SCREAMING_CASE) on the outside, original casing on the wire. The fromWire companion makes deserialization trivial.

Generating Action Factory Functions

Actions defined in the schema get typed factory functions so you can't accidentally mis-construct them:

Generated — Actions.kt (excerpt)
fun ActionScope.navigate(
    destination: String,
    params: Map<String, String> = emptyMap()
) {
    addAction("navigate", buildProps {
        prop("destination", destination)
        prop("params", params)
    })
}

fun ActionScope.apiCall(
    endpoint: String,
    method: HttpMethod = HttpMethod.POST,
    bodyFromState: List<String> = emptyList()
) {
    addAction("apiCall", buildProps {
        prop("endpoint", endpoint)
        prop("method", method)
        prop("bodyFromState", bodyFromState)
    })
}

fun ActionScope.showSnackbar(message: String) {
    addAction("showSnackbar", buildProps {
        prop("message", message)
    })
}

The Generated DSL in Action

Here's a more realistic example — a settings screen with conditional rendering, mixed component types, and actions:

SettingsScreen.kt — using the generated DSL
fun buildSettingsScreen(user: User, flags: FeatureFlags): ScreenDefinition {
    return screen("settings") {
        column(padding = 16.dp, spacing = 12.dp) {
            text("Settings", style = TextStyle.HEADLINE)

            sectionHeader(title = "Account")

            settingsRow(
                label = "Email",
                value = user.email,
                icon = Icon.MAIL
            )

            settingsRow(
                label = "Subscription",
                value = user.plan.displayName,
                icon = Icon.CREDIT_CARD
            ) {
                onTap { navigate("/subscription") }
            }

            if (flags.isEnabled("dark_mode_toggle")) {
                toggleRow(
                    label = "Dark Mode",
                    stateKey = "darkMode",
                    defaultValue = false
                )
            }

            spacer(height = 24.dp)

            button(
                label = "Sign Out",
                style = ButtonStyle.DESTRUCTIVE
            ) {
                onTap {
                    apiCall("/auth/logout", method = HttpMethod.POST)
                    navigate("/login")
                }
            }
        }
    }
}

That if (flags.isEnabled(...)) — that's just Kotlin. The DSL is regular code. You can use conditionals, loops, variables, functions. You can extract reusable sections into helper functions. It composes naturally because it is the host language.

Compare that to building the same conditional logic in raw JSON. You'd need a custom Conditional component type, or templating in your JSON, or string interpolation. It's ugly. With a DSL, the backend language is your templating engine. This is one of the patterns we discuss in depth in our SDUI architecture patterns guide.

BYOC: Bring Your Own Components

Here's the part that makes this really useful: the codegen works with any component library.

When you register a custom component in your mobile app — say, a ProductCard for your e-commerce screens — the schema extraction picks it up automatically. Run the code generator again, and productCard() appears in your DSL. No manual SDK updates, no writing wrappers, no coordinating releases between mobile and backend teams.

The workflow looks like this:

  1. Mobile team registers ProductCard with its props and actions in the SDK
  2. Build system extracts the schema (happens automatically on mobile build)
  3. Code generator reads the schema, produces updated Components.kt
  4. Backend team gets productCard() in their IDE — autocomplete and everything

The schema is the contract. Both sides honor it. If the mobile team renames a prop, the backend code stops compiling. That's the point — you want that friction. It's infinitely better than a runtime crash in production that you find out about from a Sentry alert at 2am.

This is the key difference from other SDUI approaches. Most frameworks give you a fixed component set. Need something custom? Fork the SDK or wait for the next release. With schema-driven codegen, your custom components are first-class citizens from day one. We covered the full getting-started flow for adding custom components in a separate post.

Handling Edge Cases

Schema Versioning and Backward Compatibility

Schemas evolve. Props get added, deprecated, and eventually removed. We handle this with a few rules:

The deprecation flow generates clean Kotlin:

// When a prop is marked deprecated in the schema:
fun ContainerScope.userAvatar(
    imageUrl: String,
    size: AvatarSize = AvatarSize.MEDIUM,
    @Deprecated(
        message = "Use imageUrl instead. Will be removed in schema v4.",
        replaceWith = ReplaceWith("imageUrl")
    )
    avatarUrl: String? = null
)

The IDE shows the deprecation warning. The ReplaceWith annotation even lets the IDE auto-fix it for you. Smooth migration, no documentation required.

Nested Containers and Child Validation

Some components accept children but only specific types. A TabBar should only contain Tab components. The schema declares this with allowedChildren, and the generator creates a scoped builder:

// Generated: TabBar only accepts Tab children
fun ContainerScope.tabBar(
    selectedIndex: Int = 0,
    children: TabBarScope.() -> Unit
) { /* ... */ }

// TabBarScope only exposes tab()
class TabBarScope : RestrictedScope {
    fun tab(label: String, icon: Icon? = null,
         content: ContainerScope.() -> Unit) { /* ... */ }
}

// Usage — try putting a text() in here and it won't compile
tabBar(selectedIndex = 0) {
    tab(label = "Home", icon = Icon.HOME) {
        text("Home content")  // this is inside the tab's ContainerScope — fine
    }
    tab(label = "Profile", icon = Icon.PERSON) {
        text("Profile content")
    }
}

Kotlin's scoped receivers make this constraint enforceable at compile time. No runtime validation needed. If you're dealing with SDUI error handling, catching these errors at build time is orders of magnitude better than catching them in production.

Trade-offs We Encountered

Build-Time Codegen vs Runtime Reflection

We considered generating the DSL at runtime using reflection — read the schema, create functions dynamically. It's simpler to implement and doesn't require a build step.

We rejected it. The whole point is compile-time safety. If you generate at runtime, you lose IDE autocomplete, you lose type checking, and you're back to "hope it works." The build step adds ~2 seconds to the backend compile. Worth it.

Generated Code Readability

Most codegen tools produce ugly output. We explicitly optimized for readability because developers will read the generated code — to debug, to understand what's available, to copy examples. Every generated file has:

Schema Evolution and Breaking Changes

When the mobile team makes a breaking schema change — renaming a component, changing a prop type — the backend code stops compiling. This is intentional. But it does mean the teams need to coordinate.

Our approach: the schema has a version number. The codegen emits a compatibility report showing what changed. Breaking changes require a schema version bump, which triggers a migration checklist. It's more process, but it beats the alternative of silently shipping broken UIs. If you're evaluating whether this level of rigor makes sense for your team, our business case for SDUI post covers the ROI math.

Code Size Growth

With 80+ components, the generated Components.kt is ~3,000 lines. It compiles fine and doesn't impact runtime performance (these are just builder functions), but it's a large file. We considered splitting by category but decided a single file with good IDE navigation was more practical.

Wire Format: DSL → JSON

The DSL is a builder. Under the hood, it constructs a tree of ComponentNode objects that serialize to the JSON wire format the mobile SDK expects.

Here's the transformation:

DSL code
val screen = screen("home") {
    column(padding = 16.dp) {
        text("Hello", style = TextStyle.HEADLINE)
        button(label = "Go") {
            onTap { navigate("/next") }
        }
    }
}
Generated JSON wire format
{
  "screenId": "home",
  "root": {
    "type": "Column",
    "props": { "padding": 16 },
    "children": [
      {
        "type": "Text",
        "props": {
          "content": "Hello",
          "style": "headline"
        }
      },
      {
        "type": "Button",
        "props": { "label": "Go" },
        "actions": {
          "onTap": {
            "type": "navigate",
            "destination": "/next"
          }
        }
      }
    ]
  }
}

The DSL compiles down to exactly the JSON your mobile SDK expects. Enum values use their wireValue. Null props are omitted. The format is stable — changing the DSL surface doesn't change the wire format as long as the schema doesn't change.

The screen() function returns a ScreenDefinition that can be serialized to JSON in one call. In a Spring Boot or Ktor endpoint, it looks like:

@GetMapping("/screens/home")
fun homeScreen(@AuthUser user: User): ScreenDefinition {
    return buildHomeScreen(user)  // returns ScreenDefinition, serialized by framework
}

Clean. The framework handles JSON serialization. The endpoint is just a function call. For performance optimization, the serialized output can be cached since the structure is deterministic for a given set of inputs.

What's Next

We're currently exploring a few extensions:

The core idea isn't specific to Kotlin or to SDUI. Any time you have a schema that defines a contract between two systems, code generation can bridge the gap between "stringly-typed and fragile" and "typed and safe." We just happened to be building server-driven UI when we got fed up enough to build the generator.

If you're dealing with the same pain — JSON payloads, no IDE support, runtime errors — consider whether your schema already contains enough information to generate a typed API. Chances are, it does. You're just not reading it yet.

Related Articles

Skip the Codegen Build

Pyramid ships with a typed DSL, schema extraction, and code generation out of the box. Bring your own components, get a typed backend DSL in minutes — not months.

Join the Waitlist