1. Introduction
When a program is just learning to walk, it’s enough to do println("OK"). But the further you go with your console project, the more often you get an “internal user”—you yourself after 20 minutes of debugging. And then, suddenly, you find out that output “however it comes out” turns any check into archaeology: you’re trying to understand what total=1200 means, why there’s no currency next to it, and where the record boundaries even are.
Formatting is not about beauty; it’s about a contract between your code and a person: what is a header, what is a data row, where the number is, where the text is, which fields are “columns,” and what to do if a line is longer than expected. Plus, good formatting often helps catch bugs: for example, if the totals column “shifts,” it means somewhere you unexpectedly got text instead of a number.
To keep things practical, we’ll continue our hypothetical console project “expense tracking.” It’s still without classes, so we’ll store expenses as Triple(category, amount, note).
2. String interpolation: $x and ${expr}
String interpolation in Kotlin is when you insert a value directly into a string: $x for a simple name and ${expr} for an expression. It’s a basic thing, but it’s exactly what most often makes output readable without extra + and without “duct-tape concatenation.”
It’s important to feel the difference: concatenation with + literally glues pieces together, while string templates let you write almost “human text” with substitutions. For reports and user-facing messages, this almost always wins.
Basic example: printing an expense
Let’s start simple. Suppose an expense is a Triple<String, Int, String>: category, amount, comment.
fun main() {
val expense = Triple("food", 450, "coffee and croissant")
val category = expense.first
val amount = expense.second
val note = expense.third
println("Expense: category=$category, amount=$amount, note=\"$note\"")
// Expense: category=food, amount=450, note="coffee and croissant"
}
Notice the quotes around note: it’s a small detail, but sometimes it’s useful to visually see where the text starts and ends.
When you need ${expr}
Sometimes you want to embed the result of a computation. For example, show the amount with a “sign” or calculate tax.
fun main() {
val amount = 450
val tax = (amount * 0.2).toInt()
println("Amount=$amount, tax(20%)=$tax, total=${amount + tax}")
// Amount=450, tax(20%)=90, total=540
}
This is exactly where you can see why braces are needed: inside ${...} you can write an expression.
On readability: don’t turn a string into a “dissertation”
A good heuristic is: if a three-line expression appears inside a string, it’s time to extract the computation into a separate val. In other words, formatting should not hide logic. Kotlin lets you embed “anything,” but you’re writing code not only for the compiler, but also for your future self.
3. Separate “compute” and “display”
One of the common beginner problems: calculations, parsing strings, and printing get mixed into one salad. Does it work? Sometimes. Is it clear? Usually not. If you’ve ever had to debug a program by reading its output, you know: the more “cleanly” the stages are separated, the easier it is to spot an error.
Let’s follow a discipline: functions that compute return data, and functions that format return a string. And then main() decides whether to print it or not. This makes the program more predictable and easier to test (even if we haven’t studied testing yet—the principle is still useful).
Format a single row with a dedicated function
fun formatExpense(expense: Triple<String, Int, String>): String {
val category = expense.first
val amount = expense.second
val note = expense.third
return "[$category] $amount — $note"
}
fun main() {
val e = Triple("transport", 120, "metro")
println(formatExpense(e)) // [transport] 120 — metro
}
Notice: formatExpense prints nothing; it only returns a string. This is a small but very important style choice.
Formatting and logging are different things
In console learning projects, you want to print everything. That’s normal at the beginning. But that’s exactly why it’s useful to build the habit now: “formatting” is about a message to the user or a report, not about chaotic println("x=$x") in the middle of an algorithm.
4. Column alignment: padEnd() and padStart()
When you have more than one field, output “in one line separated by commas” quickly becomes unreadable. The human eye likes columns: text on the left, numbers on the right. In a GUI, a table does this; in the console, spaces do. And here Kotlin provides a very friendly tool: padEnd(width) and padStart(width).
The idea is simple: a string is padded with spaces to a target width. padEnd adds spaces on the right, padStart adds spaces on the left. And yes, it sounds like “just spaces,” but these are exactly the spaces that unexpectedly save you a lot of nerves.
Mini table: header and one row
fun main() {
val nameW = 12
val amountW = 8
val header = "CATEGORY".padEnd(nameW) + "AMOUNT".padStart(amountW)
val row = "food".padEnd(nameW) + "450".padStart(amountW)
println(header)
// CATEGORY AMOUNT
println(row)
// food 450
}
An important detail here: we print numbers as strings for now: "450".
What’s the difference between padEnd and padStart
Sometimes it’s easier to see it:
| Method | Where it adds spaces | Typical use case |
|---|---|---|
|
to the right | text column on the left |
|
to the left | numeric column on the right |
In the console, numbers are usually aligned to the right edge, because it’s easier to compare magnitudes, especially when the lengths differ: 5, 50, 5000.
Aligning numbers: first toString(), then padStart()
A common micro-mistake is trying to call padStart on a number directly. padStart is a string method, so you need to convert the number to a string.
fun main() {
val amount = 450
val amountW = 8
val cell = amount.toString().padStart(amountW)
println("|$cell|") // | 450|
}
It looks funny, but |...| really helps you see the spaces.
Why it’s better to store widths in variables
If you use width 10 in one place and 12 in another, the columns will “drift.” So it’s better to agree that widths are a small formatting configuration.
fun main() {
val categoryW = 14
val amountW = 8
val cat = "transport".padEnd(categoryW)
val amt = 120.toString().padStart(amountW)
println(cat + amt) // transport 120
}
If the text is longer than the width
padEnd doesn’t cut the string; it only pads. If a category arrives with length 50 characters, it will simply push everything to the right. In the console, it looks like “the formatting broke.” That’s why people sometimes introduce a rule: “if it’s longer—cut it.”
We won’t use anything “advanced” yet—only what’s already familiar: length and substring.
fun fitLeft(text: String, width: Int): String {
val cut = if (text.length > width) text.substring(0, width) else text
return cut.padEnd(width)
}
fun main() {
val w = 10
println(fitLeft("very-very-long-category", w) + "|") // very-very-|
}
Yes, the word was cut. That’s fine: a console table is not elastic. The main thing is that the rule is now explicit and consistent everywhere.
5. Multi-line output: joinToString() and raw strings
When you print a report, you usually have a set of lines: a header, a separator line, data rows, a total. The most straightforward approach is many println(...). But there’s a cleaner idea: build a list of lines, then combine them into one multi-line text.
joinToString(...) is perfect for this: it turns a collection of elements into one string with a separator, prefix, postfix, and other settings. And if you set the separator to "\n", you get a multi-line text.
Building a report as List<String>
fun main() {
val lines = listOf(
"CATEGORY".padEnd(12) + "AMOUNT".padStart(8),
"------------" + "--------",
"food".padEnd(12) + "450".padStart(8),
"transport".padEnd(12) + "120".padStart(8)
)
val report = lines.joinToString(separator = "\n")
println(report)
}
The key idea here: first you get “logical report lines,” then you join them into the final text. This is easier to maintain than ten println calls mixed with computations.
prefix and postfix for decoration
joinToString can add a prefix and postfix, which is sometimes handy for formatting.
fun main() {
val rows = listOf("A", "B", "C")
val text = rows.joinToString(
separator = "\n",
prefix = "Report:\n",
postfix = "\n(end)"
)
println(text)
// Report:
// A
// B
// C
// (end)
}
Prefix and postfix aren’t “formatting magic”—they’re just two more string pieces. But when the standard function provides them, there’s less chance to make a mistake.
Multi-line strings """...""" as a template
Sometimes it’s more convenient not to assemble a report from many small pieces, but to have a multi-line “template”—like a form. Kotlin has raw strings for this: triple quotes """...""".
The plus of such strings is that you can freely write quotes and line breaks inside, and it’s also convenient to use trimIndent() to keep indentation nice in code without dragging it into the output.
fun main() {
val title = "Expenses report"
val header = """
=== $title ===
Columns: category | amount | note
""".trimIndent()
println(header)
// === Expenses report ===
// Columns: category | amount | note
}
Notice: we still use $title. A raw string doesn’t cancel string templates—it just makes line breaks natural.
6. Practical example: an expense report
Now let’s build a small “practical” piece for our expense-tracking project: a function that takes a list of expenses and returns a nicely formatted report text. We’ll use only what we’ve discussed: string templates, padStart/padEnd, building lines into a list, and joinToString("\n").
Expense data is still a Triple(category, amount, note). Yes, Triple isn’t the most convenient type in the world, but it works honestly until we switch to classes.
Formatting a single table row
fun formatExpenseRow(
expense: Triple<String, Int, String>,
categoryW: Int,
amountW: Int
): String {
val category = expense.first
val amount = expense.second
val note = expense.third
val safeCategory = if (category.length > categoryW) category.substring(0, categoryW) else category
val catCell = safeCategory.padEnd(categoryW)
val amountCell = amount.toString().padStart(amountW)
return "$catCell$amountCell $note"
}
Here we created two “contracts”: the category width and the amount width. Plus we noticed a “danger spot”—a long category—and made an explicit decision to cut it.
The full report as one multi-line string
fun buildExpensesReport(expenses: List<Triple<String, Int, String>>): String {
val categoryW = 14
val amountW = 8
val title = "EXPENSES"
val header = "CATEGORY".padEnd(categoryW) + "AMOUNT".padStart(amountW) + " NOTE"
val separator = "-".repeat(categoryW) + "-".repeat(amountW) + " ----"
val rows = expenses.map { e ->
formatExpenseRow(e, categoryW, amountW)
}
val total = expenses.sumOf { it.second }
val footer = "TOTAL".padEnd(categoryW) + total.toString().padStart(amountW)
val lines = listOf(
"== $title ==",
header,
separator
) + rows + listOf(separator, footer)
return lines.joinToString(separator = "\n")
}
There are several important observations here.
First, we don’t print anything inside buildExpensesReport. We return a string—this is more convenient than “printing along the way.” Second, we explicitly fix column widths and use them consistently everywhere. Third, the report is built as a list of lines, and at the end it becomes multi-line text via joinToString("\n").
Using it in main()
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee and croissant"),
Triple("transport", 120, "metro"),
Triple("books", 900, "kotlin book")
)
val report = buildExpensesReport(expenses)
println(report)
// == EXPENSES ==
// CATEGORY AMOUNT NOTE
// ------------------------ ----
// food 450 coffee and croissant
// transport 120 metro
// books 900 kotlin book
// ------------------------ ----
// TOTAL 1470
}
If you’re thinking “this looks like a tiny terminal UI,” yes—that’s exactly how console reports are born: from spaces, discipline, and a bit of paranoia about widths.
7. Common mistakes
Mistake #1: mixing computation and printing in one place until the code becomes “spaghetti.”
Beginners often do this: compute a sum—immediately println, then another println in the middle of the loop, then also inside an if. As a result, you can’t reuse the output, you can’t build a report as a string, and it’s hard to verify correctness. The cure is a habit: a formatting function returns String, and printing lives in one or two places.
Mistake #2: expecting padStart/padEnd to “figure out” how to align a number by themselves.
padStart works with a string, not a number. So the “correct ritual” is: amount.toString().padStart(width). If you forget toString(), you’ll either get a compilation error or start doing weird workarounds.
Mistake #3: not fixing column widths and scattering 10, 12, 8 throughout the code.
At first it seems “obvious enough.” Then you change the header width in one place and data rows in another—and the table falls apart. It’s much more reliable to have val categoryW = ..., val amountW = ... and use them as a mini report configuration.
Mistake #4: not thinking about long strings, which makes the entire table “shift.”
padEnd doesn’t cut the string. If the category unexpectedly contains 40 characters of text, it will shift the other columns and make the report unreadable. You need to choose a policy in advance: cut (substring), shorten with an ellipsis, or wrap to the next line. For a learning version, a simple “cut to width” is enough.
Mistake #5: building a multi-line report with dozens of println calls, then being surprised it’s hard to change the formatting.
When a report is created as a single string via joinToString("\n"), you can easily save it, return it from a function, show it partially, or add something above/below. When a report is printed “as you go,” you lose control.