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 #

FunctionDescriptionTrue examplesFalse 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 #

  • Char isn’t Int in Kotlin — they’re separate types. Conversion must be explicit: .code to get the Unicode value, .toChar() to convert an Int to a Char.
  • Classification functions like isLetter(), isDigit(), isUpperCase(), isWhitespace(), and isLetterOrDigit() are the main Char API, avoiding the need to remember ASCII code ranges.
  • uppercaseChar() and lowercaseChar() (not the deprecated toUpperCase()/toLowerCase()) for case conversion. Use java.util.Locale.ROOT for 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, groupingBy can 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). .length on a String with emojis isn’t the same as the visual character count — use .codePointCount() for an accurate count.
  • filter { it.isLetter() } and filter { 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 when on Char is lighter and easier to read than complex regex.

← Previous: Numbers   Next: Sequences →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact