DSL in Kotlin: How to Create Your Own Programming Language in an Evening

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

What is a DSL and why do you need it?

\n\n

Imagine you are writing not code, but instructions in a clear language that themselves turn into a working program. That's exactly how domain-specific languages (DSL) work. Unlike general-purpose languages (Java, Python, C#), a DSL is created to solve one specific task — be it server configuration, describing business rules, or building complex SQL queries.

\n\n

Kotlin is an ideal candidate for creating DSLs. Thanks to lambdas with receivers, extensions, and type-safe builders, you can write code that looks like native syntax. In this article, we will cover not just theory, but create a real DSL for a testing framework — and you will see how simple and engaging it is.

\n\n

Why is Kotlin the King of DSL?

\n\n

Kotlin has won the love of developers not only because of its compatibility with Java. Its syntactic capabilities allow you to create internal DSLs — languages that live inside the host language and use its syntax. Here are the three pillars on which any Kotlin DSL rests:

\n\n
    \n
  • Lambdas with receivers — allow you to call object methods without explicitly specifying the name. This is the foundation of the builder pattern.
  • \n
  • Extensions — add new functions to any class, even if you cannot modify it.
  • \n
  • Infix functions — allow you to write code in a natural language style: should beEqualTo 5.
  • \n
\n\n

These capabilities turn writing a DSL into a Lego constructor: you take ready-made blocks — lambdas, functions, properties — and assemble a clear and safe language from them.

\n\n

Creating a Simple DSL for Tests: Step by Step

\n\n

Let's write a DSL for writing tests. The goal is for tests to look like a readable description, not a set of assert calls:

\n\n
test("Calculator should add numbers") {\n    val result = Calculator.add(2, 3)\n    result should beEqualTo 5\n    result shouldNot beNull\n}
\n\n

Admit it, this is almost English. Now let's break down how it works.

\n\n

Step 1. Create the test context

\n\n

We need a test function that takes a name and a block of code. The block executes in a context where should and shouldNot are available:

\n\n
class TestContext {\n    fun Any.should(matcher: Matcher) {\n        if (!matcher.matches(this)) {\n            throw AssertionError("Expected ${matcher.expected}, but got $this")\n        }\n    }\n\n    fun Any.shouldNot(matcher: Matcher) {\n        if (matcher.matches(this)) {\n            throw AssertionError("Expected not ${matcher.expected}, but got $this")\n        }\n    }\n}\n\nfun test(name: String, block: TestContext.() -> Unit) {\n    println("Running test: $name")\n    try {\n        TestContext().block()\n        println("✓ Passed")\n    } catch (e: AssertionError) {\n        println("✗ Failed: ${e.message}")\n    }\n}
\n\n

The key point: TestContext.() -> Unit is a lambda with a receiver. Inside the block, this refers to the TestContext object, so the should and shouldNot methods become implicitly available.

\n\n

Step 2. Implement matchers

\n\n

Now let's create simple matchers. beEqualTo and beNull are functions that return Matcher objects:

\n\n
interface Matcher {\n    val expected: String\n    fun matches(value: Any?): Boolean\n}\n\nfun beEqualTo(expected: Any) = object : Matcher {\n    override val expected = "equal to $expected"\n    override fun matches(value: Any?) = value == expected\n}\n\nval beNull = object : Matcher {\n    override val expected = "null"\n    override fun matches(value: Any?) = value == null\n}
\n\n

Blogs

Book Recommendations