Hacking Kotlin — Writing a DSL
Intro
In this post I wanted to share something I’m really excited about that Kotlin not only allows for but makes a breeze! That “thing” is hacking together your own Domain Specific Language or DSL for short.
On its own, this article will not give you a step-by-step guide to apply to your own projects but rather highlight some features of Kotlin that I took advantage of to build a DSL in my own project. Doing this proved to be invaluable for me and my intention is to provide an “Aha!” moment, perhaps opening a new door or way of thinking when setting up your own project architecture and utilities.
The DSL I will be documenting in this article was pulled from a project I built recently using Kotlin/JS to make a PokeDex web app. Some of the sections may be unfamiliar because of the variation but it is still Kotlin at its core. If you are familiar with Kotlin, you’ll be able to follow along. I will go through a few key points briefly before we jump into the main content.
The big differences with Kotlin/JS that are relevant to this article are the usage of dynamic
, attrs
, and props
.
- The
dynamic
type is essentially a non-compiler checked version ofAny
. You set values on adynamic
object without type-safety and must handle it cautiously when accessing those values. attrs
is the field on a UI element where you apply attributes like text color, width, etc.props
are a feature of React that can be thought of as whatever data you pass from parent to child component like arguments in a function call.
Objective
I’d like to start by explaining what problem this DSL solved for me and then go into some further detail of the code before reeling it back in to give a nice closing summary.
I was working in React and trying to find a practical, efficient way to pass props
from one component to another. There are some rudimentary ways to do this but they are very loose and I don’t like loose. I like jumping around in my codebase and being able to piece together exact behavior without needing to build or run the code. With that goal in mind, my DSL requirements were:
- A way to build a component with a lightweight, idiomatic syntax.
- Have a set of required props that the component needs.
- Supply any number of optional props that could be applied or “drilled” down the chain.
- The option to pass components to be rendered as children.
- Be as generic as possible and easily be able to make component classes compatible.
I wanted to be able to call pokemonDetailView
to build my component and then supply what is needed within its scope. This conforms to the syntax and idioms of the rest of the Kotlin/React wrapper which you may recognize is very similar to JetBrain’s Compose UI.
I had an idea of how I wanted this to be designed, and then tweaked the implementation until I got it just right. Here’s what the usage ended up looking like; exactly what I was trying to achieve.
override fun RBuilder.render() {
browserRouter {
switch {
…
route<IdProps>("/pokemon/:id") { props ->
//call the component builder
pokemonDetailView { optionalProps, children ->
//supply optional props
optionalProps {
optionalValue1 = "Testing, 1..2..3.."
optionalValue2 = 7357
}
//provide children to the target component
children {
div {
+"Hello, DSL!"
}
}
//end with returning the required props class
PokemonDetailProps(props.match.params.id)
}
}
}
}
}
Key Concepts
Understanding some key concepts:
The following sections will get into the details of the DSL, the main concepts to understand going into it are
- Scoped functions, like
String.() -> Unit
- Generic types, ceilings, Type parameters, etc
- Passing functions as parameters
(::function)
- Type Alias
If you are familiar with all of these, feel free to skip the following overview and get to the Component Builder DSL.
Otherwise, let’s take a brief look at each concept.
Scoped Functions
Kotlin treats function signatures as types and as such there are a few different variations.
For example, take a look at these two function types and how they differ in usage.
fun main() {
// Using a typical function argument
// with a single parameter, the default
// scope capture will be 'it'.
"Hello It".scopeIt { //it: String
println(it)
}
// Using a function block argument, the
// block type will be scoped as this.
"Hello This".scopeThis { //this: String
println(length) //this.length
}
}
fun String.scopeIt(block: (String) -> Unit) {
block(this)
}
fun String.scopeThis(block: String.() -> Unit) {
block(this)
}
Output:
Hello It
10
Using this
as a lambda receiver scope is useful when assigning values in a builder or for quick field access.
I used String extension functions for a simple example but there are many benefits when working with your own custom classes.
Here’s an excellent breakdown of how Kotlin’s provided scope functions differ and work under the hood: https://www.baeldung.com/kotlin/scope-functions
Type Parameters (Generics)
I don’t know that explaining Type Parameters is in the scope of this article and honestly I’m probably not the best person to teach it. I recommend either the following CodeLab or video overview for additional context.
CodeLab: https://developer.android.com/codelabs/kotlin-bootcamp-generics
Overview: https://www.youtube.com/watch?v=V-3L2TEdJXs
Passing functions as parameters
You can use Bound callable references to get access to fields and properties of an instance by prefixing with ::
. There are other usages of this operator but in the case of a function it will get the invokable instance of the function itself rather than a function call. Such as:
fun main() {
runOutput(::output)
}
fun runOutput(block: (String) -> Unit) {
block("Hello, world!")
}
fun output(value: String) {
println(value)
}
Output:
Hello, world!
Read more at https://kotlinlang.org/docs/whatsnew11.html#bound-callable-references
Type Alias
A Type Alias is essentially just a way to rename types. It doesn’t add any new functionality and the only upside of using them is to clean up your code. The only downside is that using them excessively or with poor judgement can actually make your code less readable.
You can read more about how and why to use type alias here: https://www.baeldung.com/kotlin/type-aliases
I will use any excuse I can to link to baeldung.com, the quality of that site for any Java/Kotlin developer is unparalleled.
This concludes the introduction, we will now look at each part of the DSL in detail.
DSL Entry Point — implementation
The first task is to provide a clean entry point into the DSL. I wanted the call site to be as simple to use as possible while enforcing rules and allowing for some controlled variations.
/*
/Provide a function that will be called from
/the parent component, enforce the DSL, and
/return the target component.
*/
fun RBuilder.pokemonDetailView(
block: ComponentBuilderArgs<PokemonDetailProps>
) = child(PokemonDetailView::class) { //this: RElementBuilder<P>.() -> Unit
handler(block)
}
A function like this gets created for each component that should be buildable. Its name is the element being built and the block type parameter is the prop
class that the component requires which will always have an upper limit of RProps
.
The two parameters for child
are the class to build and a function with the signature RElementBuilder<P>.() -> Unit
. The function parameter is what allows the lambda to call handler(block)
, because handler
is an RElementBuilder
Extension Function and we are scoped into it through RElementBuilder.()
.
Before reviewing the handler let’s take a look at the block
type: ComponentBuilderArgs.
Type Alias: ComponentBuilderArgs
This was a cumbersome type with a lot going on so I lifted it up into a type alias for easier reading.
/*
/Type alias for a complex function type.
/This keeps the function signatures clean.
*/
private typealias ComponentBuilderArgs <P> = (
optionalProps: (dynamic.() -> Unit) -> Unit,
children: (RBuilder.() -> dynamic) -> Unit
) -> P
First, it accepts a generic type <P>
and returns a function with multiple contained functions, each scoped for this
, with a return type of the generic <P>
. I didn’t see the need to break the type up any further but if I wanted to it would look like this:
private typealias ComponentBuilderArgs <P> = (
optionalProps: OptionalProps,
children: Children
) -> P
private typealias OptionalProps = (dynamic.() -> Unit) -> Unit
private typealias Children = (RBuilder.() -> dynamic) -> Unit
Understanding what’s going on with these function types now, we can connect the dots at the call site.
DSL Entry Point — usage
pokemonDetailView { optionalProps, children ->
//supply optional props
optionalProps { //this: Dynamic
optionalValue1 = "Testing, 1..2..3.."
optionalValue2 = 7357
//return Unit
}
//provide children to the target component
children { //this: RBuilder
div {
+"Hello, DSL!"
}
//return Unit
}
//end with returning the required props class
(return) PokemonDetailProps(props.match.params.id)
}
It is worth noting that Kotlin treats the last line of any function as the return value whether it is expected or not which is exploited in this DSL.
optionalProps
is (dynamic.() -> Unit) -> Unit
children
is (RBuilder.() -> dynamic) -> Unit
Because optionalProps
and children
are passed through as parameters with no expectation of a return value neither is required to be used. The following would be perfectly fine which was what this DSL was designed for.
pokemonDetailView { optionalProps, children ->
PokemonDetailProps(props.match.params.id)
}
Our function gives two named scope values corresponding to the types with an expected return of the given type parameter. In this case PokemonDetailProps
as we saw previously.
With our type alias understood, we can examine the handler.
Handler — Main DSL
/*
/ Handler that gets called on a newly
/ built component, applying any and all
/ supplied modifications.
*/
fun <P : RProps> RElementBuilder<P>.handler(block: ComponentBuilderArgs<P>) {
// Provide a function that can be called
// with a dynamic scope, allowing any
// props to be passed
fun onOptionalProps(props: dynamic.() -> Unit) {
// Pass the attributes of this
// component to the caller
props(this.attrs)
}
// Provide a function that can be called
// with an RBuilder scope, which is
// the base of each component.
fun onChildren(children: RBuilder.() -> Unit) {
// Pass this component in for children
// to be passed through by the caller
children(this)
}
// Call the block with each optional
// function, and return the required props
// as defined in ComponentBuilderArgs
val result = block(::onOptionalProps, ::onChildren)
// Apply each supplied prop as required
result.asJsObject().getOwnPropertyNames().forEach {
attrs.asDynamic()[it] = result.asDynamic()[it]
}
}
We just covered this but to recap, our argument for block
in this function is the verbose type alias function.
This part was a little tough for me to wrap my head around at first but as I unraveled it things started to click.
Since we call this function with values specified in the block we don’t know whether the optional props or children were provided and the handler is agnostic to it. When block()
is called those functions will be provided to the call site whether they are used or not. We get the provided required props in result
from the block()
call and then are able to iterate over them to apply to our component’s attributes.
It may seem a bit convoluted but when you want things to work a very specific way when developing it is ok for things to get a little weird under the surface.
Now that we’ve looked at all the parts individually you can see the full code flows below.
Full DSL code and usage
pokemonDetailView { optionalProps, children ->
optionalProps {
optionalValue1 = "Testing, 1..2..3.."
optionalValue2 = 7357
}
children {
div {
+"Hello, DSL!"
}
}
PokemonDetailProps(props.match.params.id)
}
private typealias ComponentBuilderArgs <P> = (
optionalProps: (dynamic.() -> Unit) -> Unit,
children: (RBuilder.() -> dynamic) -> Unit
) -> P
fun <P : RProps> RElementBuilder<P>.handler(
block: ComponentBuilderArgs<P>
) {
fun onOptionalProps(props: dynamic.() -> Unit) {
props(this.attrs)
}
fun onChildren(children: RBuilder.() -> Unit) {
children(this)
}
val result = block(::onOptionalProps, ::onChildren)
result.asJsObject().getOwnPropertyNames().forEach {
attrs.asDynamic()[it] = result.asDynamic()[it]
}
}
data class PokemonDetailProps(var pokemonId: Int) : RProps
fun RBuilder.pokemonDetailView(
block: ComponentBuilderArgs<PokemonDetailProps>
) = child(PokemonDetailView::class) {
handler(block)
}
Wrapping Up
With this DSL written my objective is complete. Now whenever I build a new component, I will simply create a variation of the extension function like RBuilder.[MyComponent]
and just use that Component’s prop type class as the generic type. This allows me to compose views rapidly and, more importantly, cleanly.
Was the time spent writing this DSL worth the time saved on the project? Maybe not. However, it did teach me a lot about how things work in Kotlin and what options I have as a developer to set up my environment to meet my needs. I hope that learning these concepts will make you feel empowered to make quality of life changes early on in a project and confident that it will pay off down the road in efficiency just as it did for me.
As always, thanks for reading and I hope you took away something valuable from this article. Remember to check back periodically as this is where I will post all of my hands-on content!
Special thanks to my friend and colleague Kyle Dahn for his help letting me bounce my ideas off of him and proofreading my articles.
https://www.linkedin.com/in/kdahn/