Characters #
Char is a type that’s often overlooked — most developers work directly with String without ever touching individual characters. Yet there are many situations where working at the character level is more appropriate: validating input formats character by character, building simple parsers, classifying text content, or manipulating binary data. Kotlin provides a rich Char API — far cleaner than Java which still relies on integer comparisons and the static Character methods. This article covers the Char type thoroughly: its representation as Unicode, built-in classification functions, operations you can perform, how it works with String, and idiomatic patterns for character-level text processing.
Char as a Type #
In Kotlin, Char is its own type — not an integer, not a byte. This differs from C/C++ where char is essentially a small number. Kotlin separates Char from Int strictly at the type system level.
// Creating Chars
val huruf: Char = 'A'
val angka: Char = '7'
val spasi: Char = ' '
val newline: Char = '\n'
// Char and Int are DIFFERENT types in Kotlin
val c: Char = 'A'
// val n: Int = c // ERROR: Type mismatch
// Explicit conversion is required
val kode: Int = c.code // 65 — the Unicode/ASCII code
val kembali: Char = 65.toChar() // 'A'
// Direct comparison between types isn't possible
// println(c == 65) // ERROR
println(c.code == 65) // true
println(c == 'A') // true
// Char supports relational operators
println('A' < 'Z') // true (based on Unicode codes)
println('a' > 'A') // true ('a' = 97, 'A' = 65)
println('0' < '9') // true
flowchart LR
A["Char 'A'"] --> B["Unicode representation\nU+0041"]
B --> C["Numeric code\n.code = 65"]
C --> D["Back to Char\n65.toChar() = 'A'"]
A --> E["String\n.toString() = \"A\""]
A --> F["Relational operations\n'A' < 'Z' → true"]Escape Characters #
// Escape characters available in Kotlin
val tab = '\t' // Horizontal tab
val newline = '\n' // Newline (Line Feed)
val cr = '\r' // Carriage Return
val backslash = '\\' // Backslash
val petik = '\'' // Single quote
val petikGanda = '\"' // Double quote (optional in Char, required in String)
val null_char = '\u0000' // Null character
val unicode = '\u03B1' // α — Greek alpha
// Unicode escape — \uXXXX format
val bintang = '\u2605' // ★
val hati = '\u2665' // ♥
val centang = '\u2713' // ✓
val silang = '\u2717' // ✗
val derajat = '\u00B0' // °
val euro = '\u20AC' // €
val rupiah = '\u20B9' // ₹ (not Rupiah, but the Indian Rupee)
println("Suhu: 37${'\u00B0'}C") // Suhu: 37°C
Character Classification #
This is the most useful part of the Char API — functions for classifying characters without having to remember ASCII code ranges.
The isXxx Functions #
val c = 'A'
// Letter classification
println('A'.isLetter()) // true — all Unicode letters
println('5'.isLetter()) // false
println('α'.isLetter()) // true — including non-Latin letters
println('A'.isUpperCase()) // true
println('a'.isLowerCase()) // true
println('A'.isUpperCase()) // true
println('5'.isUpperCase()) // false
// Digit classification
println('5'.isDigit()) // true — digits 0-9
println('A'.isDigit()) // false
println('5'.isDigit()) // true
println('٥'.isDigit()) // true — Arabic digits, depending on the implementation
// Letters and digits combined
println('A'.isLetterOrDigit()) // true
println('5'.isLetterOrDigit()) // true
println('_'.isLetterOrDigit()) // false
println('@'.isLetterOrDigit()) // false
// Whitespace
println(' '.isWhitespace()) // true
println('\t'.isWhitespace()) // true
println('\n'.isWhitespace()) // true
println('A'.isWhitespace()) // false
// Punctuation and symbols
println('.'.isLetterOrDigit()) // false
println('_'.isLetterOrDigit()) // false
The Complete Classification Function Table #
| Function | Description | True examples | False examples |
|---|---|---|---|
isLetter() | Unicode letters | 'A', 'α', 'あ' | '5', '@' |
isDigit() | Digits 0–9 | '0', '9' | 'A', ' ' |
isLetterOrDigit() | Letters or digits | 'A', '5' | '_', '@' |
isUpperCase() | Uppercase letters | 'A', 'Z' | 'a', '5' |
isLowerCase() | Lowercase letters | 'a', 'z' | 'A', '5' |
isWhitespace() | Spaces, tabs, newlines | ' ', '\t', '\n' | 'A', '_' |
isISOControl() | Control characters | '\t', '\n' | 'A', ' ' |
Case Conversion #
// Uppercase and Lowercase
println('a'.uppercaseChar()) // 'A'
println('A'.lowercaseChar()) // 'a'
println('5'.uppercaseChar()) // '5' — digits don't change
println('α'.uppercaseChar()) // 'Α' — Greek letters are also converted
// titlecase — used for the first letter of words
println('a'.titlecaseChar()) // 'A' — the same as uppercase for Latin
// But different for some Unicode scripts like DZ → Dz (titlecase) vs DZ (uppercase)
// Old deprecated versions — avoid
// 'a'.toUpperCase() // deprecated
// 'a'.toLowerCase() // deprecated
// Usage in String transformations
fun String.titleCase(): String = split(" ").joinToString(" ") { kata ->
if (kata.isEmpty()) kata
else kata[0].uppercaseChar() + kata.substring(1).lowercase()
}
println("halo dunia kotlin".titleCase()) // Halo Dunia Kotlin
// Check whether conversion is needed
fun String.sudahTitleCase(): Boolean = split(" ").all { kata ->
kata.isEmpty() || (kata[0].isUpperCase() && kata.drop(1).all { !it.isUpperCase() })
}
Arithmetic Operations on Char #
Char supports limited arithmetic operations — addition and subtraction with Int, as well as subtraction between Chars.
// Char + Int = Char
val a = 'A'
println(a + 1) // 'B'
println(a + 25) // 'Z'
// Char - Int = Char
val z = 'Z'
println(z - 1) // 'Y'
println(z - 25) // 'A'
// Char - Char = Int (the distance between two characters)
println('Z' - 'A') // 25
println('z' - 'a') // 25
println('9' - '0') // 9 — useful for converting digits to numbers
// Application: shifting letters (a simple Caesar cipher)
fun geserHuruf(c: Char, geser: Int): Char {
return when {
c.isUpperCase() -> 'A' + (c - 'A' + geser).mod(26)
c.isLowerCase() -> 'a' + (c - 'a' + geser).mod(26)
else -> c // not a letter, leave it as is
}
}
fun enkripsiCaesar(teks: String, geser: Int): String =
teks.map { geserHuruf(it, geser) }.joinToString("")
println(enkripsiCaesar("Halo Dunia", 3)) // Kdor Gxqld
println(enkripsiCaesar("Kdor Gxqld", -3)) // Halo Dunia
// Iterating the alphabet with arithmetic
val alfabet = (0..25).map { 'A' + it }
println(alfabet.joinToString("")) // ABCDEFGHIJKLMNOPQRSTUVWXYZ
// Converting a digit character to an Int
val digitChar = '7'
val nilaiInt = digitChar - '0' // 7 — the manual way
val nilaiInt2 = digitChar.digitToInt() // 7 — the idiomatic way
// digitToInt with a base
'F'.digitToInt(16) // 15 (hex)
'7'.digitToInt(8) // 7 (octal)
'1'.digitToInt(2) // 1 (binary)
Char and String #
Char and String interact closely — a String is essentially a sequence of Chars.
Accessing Characters in a String #
val teks = "Kotlin"
// Index access
val pertama: Char = teks[0] // 'K'
val terakhir: Char = teks[teks.length - 1] // 'n'
val terakhir2: Char = teks.last() // 'n' — more idiomatic
// Character iteration
for (c in teks) {
print("$c ") // K o t l i n
}
// forEachIndexed
teks.forEachIndexed { index, char ->
println("$index: $char")
}
// Access as a List<Char>
val chars: List<Char> = teks.toList()
// ['K', 'o', 't', 'l', 'i', 'n']
// CharArray
val charArray: CharArray = teks.toCharArray()
val dariArray: String = String(charArray) // "Kotlin"
// first and last with predicates
val hurufBesar = teks.first { it.isUpperCase() } // 'K'
val hurufKecil = teks.last { it.isLowerCase() } // 'n'
Building Strings from Chars #
// Char to String
val c = 'A'
val s: String = c.toString() // "A"
val s2: String = "$c" // "A" (string template)
// Building a String from a List<Char>
val chars = listOf('K', 'o', 't', 'l', 'i', 'n')
val teks = chars.joinToString("") // "Kotlin"
val teks2 = String(chars.toCharArray()) // "Kotlin"
// buildString with Chars
val hasil = buildString {
append('H')
append('a')
append('l')
append('o')
}
// "Halo"
// Character transformation in a String
val input = "hElLo WoRlD"
val lowercase = input.map { it.lowercaseChar() }.joinToString("")
// "hello world"
// Or more directly:
val lowercase2 = input.lowercase()
// Filtering specific characters
val hanyaHuruf = "H3ll0 W0r1d!".filter { it.isLetter() }
// "HllWrd"
val hanyaAngka = "tel: 0812-3456-7890".filter { it.isDigit() }
// "081234567890"
val tanpaSpasi = "Kotlin is fun".filterNot { it.isWhitespace() }
// "Kotlinisfun"
Input Validation with Char #
One of the most practical uses of the Char API is input format validation — more efficient than regex for simple patterns.
// Simple phone number validation
fun isNomorTeleponValid(nomor: String): Boolean {
val bersih = nomor.filter { it.isDigit() }
return bersih.length in 10..13
}
// Password format validation
fun validasiPassword(password: String): List<String> {
val error = mutableListOf<String>()
if (password.length < 8) error.add("At least 8 characters")
if (password.none { it.isUpperCase() }) error.add("Must have an uppercase letter")
if (password.none { it.isLowerCase() }) error.add("Must have a lowercase letter")
if (password.none { it.isDigit() }) error.add("Must have a digit")
if (password.none { !it.isLetterOrDigit() }) error.add("Must have a special character")
return error
}
val hasil = validasiPassword("Kotlin99")
// ["Must have a special character"]
val hasilKuat = validasiPassword("K0tl!n99")
// [] — valid
// Credit card format validation (16 digits)
fun isKartuKreditValid(nomor: String): Boolean {
val bersih = nomor.filterNot { it == ' ' || it == '-' }
return bersih.length == 16 && bersih.all { it.isDigit() }
}
isKartuKreditValid("4111 1111 1111 1111") // true
isKartuKreditValid("4111-1111-1111-1111") // true
isKartuKreditValid("411111111111111") // false (15 digits)
// Username validation: letters, digits, underscores only
fun isUsernameValid(username: String): Boolean {
if (username.length !in 3..20) return false
if (!username[0].isLetter()) return false // must start with a letter
return username.all { it.isLetterOrDigit() || it == '_' }
}
isUsernameValid("andi_123") // true
isUsernameValid("123andi") // false (starts with a digit)
isUsernameValid("an") // false (too short)
isUsernameValid("andi-user") // false (dash not allowed)
Simple Parsers with Char #
The character level is ideal for building lightweight parsers without external libraries.
// A simple math expression parser — a tokenizer
enum class TokenType { ANGKA, PLUS, MINUS, KALI, BAGI, KURUNG_BUKA, KURUNG_TUTUP }
data class Token(val type: TokenType, val nilai: String)
fun tokenize(ekspresi: String): List<Token> {
val tokens = mutableListOf<Token>()
var i = 0
while (i < ekspresi.length) {
val c = ekspresi[i]
when {
c.isWhitespace() -> i++ // skip spaces
c.isDigit() -> {
val start = i
while (i < ekspresi.length && (ekspresi[i].isDigit() || ekspresi[i] == '.')) {
i++
}
tokens.add(Token(TokenType.ANGKA, ekspresi.substring(start, i)))
}
c == '+' -> { tokens.add(Token(TokenType.PLUS, "+")); i++ }
c == '-' -> { tokens.add(Token(TokenType.MINUS, "-")); i++ }
c == '*' -> { tokens.add(Token(TokenType.KALI, "*")); i++ }
c == '/' -> { tokens.add(Token(TokenType.BAGI, "/")); i++ }
c == '(' -> { tokens.add(Token(TokenType.KURUNG_BUKA, "(")); i++ }
c == ')' -> { tokens.add(Token(TokenType.KURUNG_TUTUP, ")")); i++ }
else -> throw IllegalArgumentException("Unknown character: $c")
}
}
return tokens
}
val tokens = tokenize("12 + 34 * (5 - 2)")
// [Token(ANGKA,"12"), Token(PLUS,"+"), Token(ANGKA,"34"), ...]
// A simple CSV parser
fun parseCSVBaris(baris: String, pemisah: Char = ','): List<String> {
val hasil = mutableListOf<String>()
val builder = StringBuilder()
var dalamPetik = false
for (c in baris) {
when {
c == '"' -> dalamPetik = !dalamPetik
c == pemisah && !dalamPetik -> {
hasil.add(builder.toString().trim())
builder.clear()
}
else -> builder.append(c)
}
}
hasil.add(builder.toString().trim())
return hasil
}
val baris = """Andi,"Jakarta, Selatan",25"""
val kolom = parseCSVBaris(baris)
// ["Andi", "Jakarta, Selatan", "25"]
Unicode and Special Characters #
Kotlin uses Unicode fully, but there are some important nuances.
Supplementary Characters (beyond the BMP) #
// A Char in Kotlin represents a UTF-16 code unit (16-bit)
// Characters beyond the BMP (Basic Multilingual Plane) need two Chars — called a surrogate pair
val emoji = "😀"
println(emoji.length) // 2 — not 1! (two UTF-16 code units)
println(emoji[0].code) // 55357 — high surrogate
println(emoji[1].code) // 56832 — low surrogate
// The actual number of Unicode characters
println(emoji.codePointCount(0, emoji.length)) // 1
// A safe way to iterate codepoints
"Halo 😀 Kotlin".codePoints().forEach { cp ->
println(cp.toChar()) // can error for supplementary, but illustrative
}
// For strings with emoji/supplementary characters:
val teksEmoji = "Halo 😀 World 🌍"
println(teksEmoji.length) // more than the visual character count
// Count visual characters correctly
fun String.panjangVisual(): Int = codePointCount(0, length)
println(teksEmoji.panjangVisual()) // the actual codepoint count
Char and Locale #
// Locale-aware case conversion
val turki = "istanbul"
println(turki.uppercase()) // ISTANBUL (default locale)
println(turki.uppercase(java.util.Locale("tr"))) // İSTANBUL (i → İ, not I, in Turkish)
// This matters for multi-language applications
// For safety, always use Locale.ROOT when converting for technical comparisons
val versi = "v1.0.0-BETA"
val versionNormal = versi.lowercase(java.util.Locale.ROOT) // "v1.0.0-beta"
Idiomatic Patterns for Character Processing #
Counting Character Frequencies #
val teks = "kotlin programming language"
// The frequency of each character
val frekuensi: Map<Char, Int> = teks
.filter { !it.isWhitespace() }
.groupingBy { it }
.eachCount()
// The most frequent character
val terbanyak = frekuensi.maxByOrNull { it.value }
println("'${terbanyak?.key}' appears ${terbanyak?.value} times")
// Unique characters
val unik = teks.filter { !it.isWhitespace() }.toSet()
println("${unik.size} unique characters")
Anagram Checker #
fun isAnagram(a: String, b: String): Boolean {
val bersihA = a.lowercase().filter { it.isLetter() }
val bersihB = b.lowercase().filter { it.isLetter() }
if (bersihA.length != bersihB.length) return false
return bersihA.groupingBy { it }.eachCount() ==
bersihB.groupingBy { it }.eachCount()
}
println(isAnagram("listen", "silent")) // true
println(isAnagram("Astronomer", "Moon starer")) // true
println(isAnagram("hello", "world")) // false
Palindrome Checker #
fun isPalindrome(teks: String): Boolean {
val bersih = teks.lowercase().filter { it.isLetterOrDigit() }
return bersih == bersih.reversed()
}
isPalindrome("katak") // true
isPalindrome("A man a plan a canal Panama") // true
isPalindrome("kotlin") // false
ROT13 Encoder #
fun rot13(teks: String): String = teks.map { c ->
when {
c in 'A'..'Z' -> 'A' + (c - 'A' + 13) % 26
c in 'a'..'z' -> 'a' + (c - 'a' + 13) % 26
else -> c
}
}.joinToString("")
println(rot13("Hello, World!")) // Uryyb, Jbeyq!
println(rot13("Uryyb, Jbeyq!")) // Hello, World! (symmetric)
Input Normalization #
// Clean input: only letters and digits, lowercase
fun normalisasi(input: String): String =
input.lowercase()
.filter { it.isLetterOrDigit() || it == ' ' }
.trim()
.replace(Regex("\\s+"), " ")
normalisasi(" Halo Dunia!! 123 ") // "halo dunia 123"
// Word censoring: replace the middle characters with *
fun sensorKata(kata: String): String {
if (kata.length <= 2) return kata
return kata.first() +
"*".repeat(kata.length - 2) +
kata.last()
}
sensorKata("password") // "p******d"
sensorKata("ab") // "ab"
sensorKata("a") // "a"
// Mask emails
fun maskEmail(email: String): String {
val atIndex = email.indexOf('@')
if (atIndex < 0) return email
val lokal = email.substring(0, atIndex)
val domain = email.substring(atIndex)
val terlihat = lokal.take(2)
val mask = "*".repeat(maxOf(0, lokal.length - 2))
return terlihat + mask + domain
}
maskEmail("[email protected]") // "an*********@example.com"
Summary #
Charisn’tIntin Kotlin — they’re separate types. Conversion must be explicit:.codeto get the Unicode value,.toChar()to convert an Int to a Char.- Classification functions like
isLetter(),isDigit(),isUpperCase(),isWhitespace(), andisLetterOrDigit()are the main Char API, avoiding the need to remember ASCII code ranges.uppercaseChar()andlowercaseChar()(not the deprecatedtoUpperCase()/toLowerCase()) for case conversion. Usejava.util.Locale.ROOTfor locale-independent technical conversions.- Char arithmetic:
Char + Int = Char,Char - Int = Char,Char - Char = Int. Useful for alphabet navigation, Caesar ciphers, and digit-to-number conversion.digitToInt()is the idiomatic way to convert a digit character to its numeric value. Supports custom bases:'F'.digitToInt(16) = 15.- A String is an Iterable — all collection operations like
filter,map,any,all,groupingBycan be used directly on a String for per-character processing.- Input validation at the character level is more efficient than regex for simple patterns:
nomor.all { it.isDigit() }is far faster than the\d+regex.- Emojis and supplementary characters need two
Chars (a surrogate pair)..lengthon a String with emojis isn’t the same as the visual character count — use.codePointCount()for an accurate count.filter { it.isLetter() }andfilter { it.isDigit() }are idiomatic patterns for cleaning input: remove everything but letters or everything but digits in one line.- For simple parsers (CSV, tokenizers, custom formats), iterating character by character with
whenonCharis lighter and easier to read than complex regex.