How We Built a Typed DSL Code Generator for Server-Driven UI
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
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:
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?
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):
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:
{
"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:
Components.kt— Extension functions for each component (the DSL surface)Models.kt— Data classes for complex prop typesEnums.kt— Enum classes extracted from prop type annotationsActions.kt— Typed factory functions for action types
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.
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:
/**
* 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:
/**
* 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:
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:
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:
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:
- Mobile team registers
ProductCardwith its props and actions in the SDK - Build system extracts the schema (happens automatically on mobile build)
- Code generator reads the schema, produces updated
Components.kt - 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:
- Adding a prop is always safe — new props are optional by default
- Removing a prop must go through deprecation first — the generator produces
@Deprecatedannotations for at least one version cycle - Changing a prop type is a breaking change — the generator flags this and refuses to proceed without an explicit override
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:
- KDoc comments pulled from schema descriptions
- Consistent formatting (we run the output through ktfmt)
- A header comment explaining that the file is generated and when
- Logical grouping by category
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:
val screen = screen("home") {
column(padding = 16.dp) {
text("Hello", style = TextStyle.HEADLINE)
button(label = "Go") {
onTap { navigate("/next") }
}
}
}
{
"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:
- Multi-language codegen — The same schema can generate TypeScript SDKs for Node.js backends, Python dataclasses for Django/FastAPI, and Swift DSLs for backends using Vapor
- Visual schema editor — A web UI where you can browse components, see their props, and preview the generated DSL output
- Schema diffing in CI — Automatically comment on PRs that change the schema with a migration impact report
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
- Server-Driven UI Tutorial: Building Dynamic Screens with Jetpack Compose →
- SDUI Architecture Patterns: Building Scalable Server-Driven UI Systems →
- SDUI Error Handling and Fallback Strategies →
- Getting Started with Pyramid: Add Server-Driven UI to Your App in 30 Minutes →
- Testing Strategies for Server-Driven UI →
- The Business Case for Server-Driven UI →
- SDUI Performance Optimization Guide →
- Why JSON-Based SDUI Breaks at Scale (And What Comes Next) →
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