1. Introduction
A mini API is a small set of functions, extensions, and (sometimes) operators that makes code closer to the domain. Not “closer to Kotlin,” not “closer to trendy DSLs,” but closer to what you actually do: add expenses, compute totals, build a report, format output. It’s a layer that saves repetition and makes main look like a scenario rather than a dump of details.
It’s very easy to confuse a mini API with “writing your own programming language in Kotlin.” But the goal is much more modest: so that the next person (and you in two weeks) can open the file and understand what’s happening, without guessing “does this plus operator add numbers or launch a rocket.” That’s why we’ll constantly check ourselves with the question: can you tell what will happen from a single line of code?
Let’s imagine we’re continuing a learning console expense-tracking app (a hypothetical ExpenseTracker). Previously we likely had a MutableList<Expense>, commands like add/list/remove, and a few reports. Now we want reports to be assembled in a readable and consistent way—and this is exactly where a mini API fits.
Three rules: “cheap,” “no surprises,” “the name shows the meaning”
When you start adding extensions and operators, the temptation appears to “hide” details so deeply that the code becomes pretty but stops being honest. Kotlin conventions suggest a guideline: if something looks like a property, it should be cheap and have no surprises; if an action is noticeable, it’s better to make it a function. This rule is especially useful here because a mini API is almost always built from “small things” that are called frequently.
Let’s formalize the three rules as a behavior checklist:
| Tool | When it fits | Reader expectations | Typical “bad surprises” |
|---|---|---|---|
| Extension function fun X.foo() | A repeated processing step, formatting, a small business operation | This is an action; it reads like a verb | foo does too much, touches I/O, mutates everything in sight |
| Extension property val X.bar | A derived value, a lightweight check | It’s “like a field”: fast, stable, no side effects | get() sorts a list, prints to the console, or calls the network |
| Operator operator fun plus/compareTo/get/set | Conventional meaning: addition, comparison, indexing | The symbol must read unambiguously | + mutates an object, [] means “add if missing,” compareTo compares “however it feels” |
| infix fun a foo b | Rare and targeted: two roles are “almost equal” (like to) | Feels like a mini phrase | Using it for mutation or where a normal function is clearer |
For infix, it’s also useful to remember the formal restrictions: an infix function must have exactly one parameter, without vararg and without a default value. Infix calls also have their own precedence, so expressions without parentheses sometimes read differently than they execute.
2. Mini model and report
Model: Expense and Money
To keep the examples coherent, let’s agree on small types we’ll build the mini API around. We’re not diving into architecture and layers—we’ll simply keep the code tidy and domain-focused.
Let’s start with money. From previous topics we already know value class, so let the money be in cents to avoid suffering from Double in finance:
@JvmInline
value class Money(val cents: Long)
And an expense:
data class Expense(
val title: String,
val amount: Money,
val category: String
)
For now, the category is a string—intentionally. Yes, we already have enum, but today is not about the model; it’s about a mini API on top of output. Categories can be improved separately, and our convenience layer should survive both a string and an enum.
Now let’s decide how we’ll build a report. The simplest option is to accumulate strings and then call joinToString("\n"). But we want adding a line to look the same everywhere, and formatting to be consistent. For that, we’ll create a small accumulator class Report.
Report as an accumulator: where += fits
The += operator is often perceived as “this is definitely about mutation.” And that’s true: by operator convention rules, a += b turns into a.plusAssign(b), if such a method exists, and the compiler expects a Unit result. This fits an accumulator perfectly: it exists to have things added to it.
Let’s make a Report that accumulates lines:
class Report {
private val lines = mutableListOf<String>()
operator fun plusAssign(line: String) {
lines.add(line)
}
override fun toString(): String = lines.joinToString("\n")
}
There’s an important predictability point here: Report is a mutable container object. So += really does mean “add inside.” This is similar to how += works for MutableList: for mutable collections, it adds elements “in place.”
Let’s verify with a mini example:
fun main() {
val r = Report()
r += "TOTAL: 350"
r += "TOP: food"
println(r)
// TOTAL: 350
// TOP: food
}
If we made it so that r + "..." mutates r, that would be worse: + is usually read as “create a new value” (like List + element, where a new collection is returned). So the rule is simple: mutation means plusAssign or an explicit method; creating a new value means plus.
Extensions for Report: less copy-paste, more consistency
Once Report appears, the next temptation is to write strings manually everywhere: "TOTAL: ${...}", "COUNT: ${...}" and so on. The problem isn’t that it’s hard—it’s that it quickly becomes “everyone does it their own way”: sometimes a colon, sometimes a dash, different spacing, different casing. A mini API exists so the style is unified and repeatable.
Let’s add an extension function that prints a key–value pair in a consistent style:
fun Report.kv(key: String, value: Any?) {
this += "$key: $value"
}
Notice: we intentionally do not make this infix and do not make it an operator. This is a normal action—“add a line”—and the name kv (key-value) is short but still readable in the context of a report.
Usage:
fun main() {
val r = Report()
r.kv("TOTAL", 350)
r.kv("COUNT", 12)
println(r)
// TOTAL: 350
// COUNT: 12
}
Now let’s add a bit more “domain” functions. For example, money printing: we don’t want to remember cents every time.
fun Money.format(): String = "%.2f".format(cents / 100.0)
And use it in Report:
fun Report.money(key: String, value: Money) {
this += "$key: ${value.format()}"
}
Now money output is stable, and main doesn’t contain “leftover” arithmetic.
3. Formatting and convenient syntax
StringBuilder mini API: chaining without magic
Sometimes it’s more convenient to build a report not through a separate class but directly as a string. Kotlin typically uses StringBuilder and the “build → return a string” approach for this. For large strings, StringBuilder is more useful than concatenation in a loop, and it’s a good moment to make a mini API on it.
Let’s make an extension function that appends a line with a newline and returns this for chaining:
fun StringBuilder.line(text: String): StringBuilder =
append(text).append('\n')
Now building looks like a “stream of lines”:
fun main() {
val text = StringBuilder()
.line("Expense report")
.line("--------------")
.line("TOTAL: 350")
.toString()
print(text)
// Expense report
// --------------
// TOTAL: 350
}
Why is this a good mini API and not “overkill”? Because the meaning is obvious: line("...") appends a line. There’s no hidden logic, no side effects, no surprising mutation of anything outside StringBuilder. The chain reads left to right like a scenario, but stays honest.
Targeted infix: when it’s truly readable
infix looks nice, but it’s tricky because it makes you want to use it “everywhere.” Conventions recommend declaring infix only when the two sides are “similar in role,” like to or zip, and avoiding infix for mutation.
Sometimes it’s convenient for us to write “key–value” pairs, especially if we later want to turn them into a Map. For example, in reports we can first collect data and then render it.
Let’s create an infix eq that builds a Pair:
infix fun String.eq(value: Any?): Pair<String, Any?> =
this to value
Usage:
fun main() {
val p = "TOTAL" eq 350
println(p) // (TOTAL, 350)
}
Here infix is justified: the string on the left is a “key,” the value on the right is a “value,” and the roles are fairly symmetric. Plus, it’s not mutation—it’s creating a value.
A practical bonus: from an array/list of Pair you can create a Map via toMap(), but it’s important to remember that keys must be unique (the last one overwrites the first).
Mini API for ExpenseTracker: a report from a list of expenses
Now let’s put everything into a small story: we have a list of expenses, and we want to build a short text report. Not a “mega system,” but a neat function you can call from main.
Let’s make a few small extension functions on the expense list. Yes, this is “an API on top of collections,” and Kotlin itself does this heavily: a huge part of the standard library is extension functions.
Let’s compute the total across all expenses:
fun List<Expense>.total(): Money {
var sum = 0L
for (e in this) sum += e.amount.cents
return Money(sum)
}
And create a report:
fun List<Expense>.toReport(): Report {
val r = Report()
r += "Expense report"
r.money("TOTAL", this.total())
r.kv("COUNT", this.size)
return r
}
Let’s test it:
fun main() {
val items = listOf(
Expense("Coffee", Money(350), "food"),
Expense("Taxi", Money(1200), "transport")
)
println(items.toReport())
// Expense report
// TOTAL: 15.50
// COUNT: 2
}
Why is this a good mini API layer?
- First, main contains almost no details: it says “I have expenses, make a report.”
- Second, toReport() is a self-explanatory function name, and it’s clear that it returns a Report object.
- Third, the mini API is small: total(), toReport(), Report.kv(), Report.money(), Money.format().
This is not a “universal library for everything,” but targeted conveniences for the task.
4. Dangerous spots and how to choose a tool
Where a mini API becomes dangerous
Once you get a taste, you’ll want to do: “what if we make any action look like a single button.” And this is where Kotlin starts testing your maturity as a developer.
For example, it’s a bad idea to make an extension property val List<Expense>.report: String, that builds a report, sorts, groups, and formats inside. It looks like a field, but it does a lot of work. Conventions recommend: a property is appropriate if the computation is cheap, doesn’t throw exceptions, and is stable in result given the same state. If your report can be expensive, it should be a function (fun buildReport()), so the reader expects “work being done.”
Another subtle point is collection operators. Many people have seen list + element and think it means “add to the list.” But for read-only collections, + returns a new list, and += for a var variable can be reassignment of the result, while for MutableList it can be in-place mutation. This isn’t a problem if you understand it, but in a mini API it’s better not to make the reader guess. That’s why we made Report an accumulator with plusAssign, where the meaning is unambiguous.
Finally, infix. Yes, a foo b looks nice. But infix has precedence, and expressions without parentheses sometimes read differently than they execute. So keep infix for very simple “paired” operations, and everything else as normal functions.
How to choose: extension / operator / infix
To avoid keeping all this in your head as “fifty rules,” it’s convenient to have a simple mental algorithm. Imagine you want to add a convenience, and ask yourself these questions in this order:
1) Does it look like "reading a property"?
Yes → make it a val (or extension-val), but only if it's cheap and has no side effects.
No → keep going.
2) Is it an action with a clear verb?
Yes → a function (regular or extension).
No → keep going.
3) Is there a conventional symbol for this (+, [], comparison)?
Yes → operator, but only if the meaning is unambiguous.
No → keep going.
4) Are the two sides "almost equal" and will it read like a phrase?
Yes → infix (rarely and carefully).
No → a normal function, and don't torture yourself.
This is not a “law of Kotlin,” but a practical habit that saves you from code that looks like magic. And magic in production is bad because it works exactly until the first bug—then you suddenly find out that the “magic wand” was actually a double-edged stick.
5. Common mistakes
Mistake No. 1: “I’ll make an operator because I can”.
Operators are not for beauty—they’re for predictability. If a + b doesn’t read as “add,” and obj[i] doesn’t read as “get by index/key,” you’re making the code worse. In such places, a regular method (addExpense, appendLine) almost always wins.
Mistake No. 2: Hiding heavy work inside an extension property.
A property looks like fast access to data, so the reader doesn’t expect sorting, grouping, report building, and especially not exceptions. Conventions recommend using a property only for cheap, stable computations. If the work is noticeable—make it a function.
Mistake No. 3: Not distinguishing + and += as “new value” vs “mutation”.
In Kotlin, += compiles under special rules: either plusAssign, or “recreate” via a = a + b. If you overload operators in your own type, keep the semantics crystal clear: plus usually creates a new value, plusAssign changes the existing one.
Mistake No. 4: Using infix for mutation or for actions with different roles.
infix looks good where the two sides are logically symmetric (like key–value or set union), and it’s bad where it’s just a normal command “do it.” Conventions explicitly recommend not making infix methods that mutate the receiver.
Mistake No. 5: Inflating a mini API into an “extension dump”.
If you put all extensions into a single Extensions.kt file with a thousand lines, you’ll get not a mini API, but mini chaos. It’s better to keep extensions thematically close to what they serve (for example, ReportExtensions.kt, MoneyFormatting.kt) and not to multiply identical names in different places, so you don’t end up fighting imports later.
Mistake No. 6: Making a mini API that you can’t “read with your eyes”.
The most unpleasant readability break is when a line of code stops being self-sufficient. If, without knowing internal conventions, you can’t tell whether we mutate an object or create a new one, whether the call is heavy, whether it has side effects—the mini API doesn’t help, it hurts. A good convenience layer stays transparent: it reduces noise but doesn’t hide meaning.
GO TO FULL VERSION