CodeGym /Courses /Kotlin SELF /Indexes and substrings in Kotlin: length, lastIndex, inde...

Indexes and substrings in Kotlin: length, lastIndex, indexOf, substring

Kotlin SELF
Level 9, Lesson 3
Available

1. A string as a sequence of characters

When you start “parsing” a string, your brain automatically wants to treat it like text from a book. But the computer does not need a book—it needs a sequence of characters where each character sits in its own position and has a number. This number is called an index, and in Kotlin (as in almost everywhere) indexing starts from zero.

Zero-based indexes: why the first character is 0

The idea that “the first element has index 0” may feel like a prank at first, but it quickly becomes convenient: index = “how many characters come before the current one”. Before the first character there are zero characters—so the index is 0. Before the second there is one—so the index is 1. This will be useful later when we cut with substring().

Let’s look at the string "Kotlin":

Character
K
o
t
l
i
n
Index
0
1
2
3
4
5

The number of characters is 6, and the last index is 5.

length and lastIndex: do not confuse “size” and “last index”

The length property tells you how many characters are in the string. And lastIndex is the index of the last character.

It is important to feel the difference: length is a “count”, lastIndex is the “last position”. In collections and arrays, the exact same logic applies: the last index equals size - 1.

Mini example:

fun main() {
    val s = "Kotlin"

    println(s.length)      // 6
    println(s.lastIndex)   // 5
}

If the string is empty (""), then length will be 0, and lastIndex will become -1. It sounds strange, but it is logical: “there is no last character”, so the “last index” goes into the negative range. That is exactly why we talk so much about boundary checks.

2. Accessing a character by index: s[i] and careful boundary checks

Now that we have indexes, your hands want to do this: “give me character number 0”. And Kotlin really does allow you to access a character by index using square brackets. But there is a catch: if the index is invalid, the program will crash with an exception (and you need to write code that does not crash because of one misplaced space).

s[i] returns Char: text is not always a string

The s[i] operator returns one character of type Char. A character is not a string. A string is many characters.

fun main() {
    val s = "Kotlin"

    val first: Char = s[0]
    val last: Char = s[s.lastIndex]

    println(first) // K
    println(last)  // n
}

If you try to do s[6] for "Kotlin", the program will crash: the indexes there are only 0..5.

Boundary checking: “before you take it, make sure you can”

Here it is useful to keep a short rule in mind: valid indexes are all numbers that are not less than 0 and less than length.

A mini helper that safely returns a character or '?' if the index is bad:

fun charOrQuestion(text: String, index: Int): Char {
    if (index < 0 || index >= text.length) return '?'
    return text[index]
}

fun main() {
    println(charOrQuestion("Kotlin", 2))   // t
    println(charOrQuestion("Kotlin", 99))  // ?
}

Empty string: the smallest, but very tricky case

An empty string is a string of length 0. It has neither a first nor a last character. This means that even s[0] is dangerous.

fun main() {
    val s = ""

    println(s.length)    // 0
    println(s.lastIndex) // -1
    println(s[0])        // you must not do this — it will be an error
}

A good habit that saves your nerves: before accessing s[0], ask yourself: “is the string definitely not empty?”

3. Searching in a string: indexOf() and checks with contains(), startsWith(), endsWith()

Usually we parse strings not out of boredom but because the string contains a delimiter: a space, @, =, a colon, a dot, and so on. The scenario is often: “find where the delimiter is, and split the string into two parts”.

indexOf(...): the -1 convention

The indexOf(...) method searches for a substring (or character) and returns the index of the first occurrence. And if it does not find it, it returns -1. This is a very important convention: -1 means “not found”.

fun main() {
    val email = "user@example.com"

    val at = email.indexOf('@')
    println(at) // 4

    val slash = email.indexOf('/')
    println(slash) // -1
}

If you forget to check for -1 and start cutting the string using that index, you will get either a boundary error or a very strange result.

contains(), startsWith(), endsWith(): when you do not need indexes

Sometimes you do not need the index—you just need “yes/no”. Then it is more convenient to use check methods.

fun main() {
    val filename = "report.txt"

    println(filename.contains("."))      // true
    println(filename.startsWith("rep"))  // true
    println(filename.endsWith(".txt"))   // true
}

These methods answer simple questions:

  • contains() — does the string contain the given substring?
  • startsWith() — does the string start with the given substring?
  • endsWith() — does the string end with the given substring?

These checks often make code safer: you access indexes manually less, and there is less chance to miss “by 1”.

4. substring(): cutting a string into parts without stepping on rakes

If indexes are “coordinates” in a string, then substring() is “scissors”. Almost any string parsing boils down to one template: find the delimiter index and cut out the left/right part.

The main rule of substring(start, end): end is excluded

The substring(start, end) method takes characters starting at position start inclusive, but end is not included. This is the classic “half-open interval” style, and it is incredibly convenient, but confusing at first.

Diagram:

"Kotlin"
 012345

substring(0, 3) -> take indexes 0,1,2 -> "Kot"

Example:

fun main() {
    val s = "Kotlin"

    println(s.substring(0, 3)) // Kot
    println(s.substring(3, 6)) // lin
}

Notice: end can be equal to length, and that is fine. “Up to the end of the string” is exactly end = length.

substring(start) — from a position to the end

There is a convenient form substring(start)—it takes from start to the end of the string.

fun main() {
    val s = "Kotlin"

    println(s.substring(2)) // tlin
}

This is very handy for the right-hand part after a delimiter: “after @ to the end”, “after = to the end”.

Boundaries of substring(): where the program can crash

substring() requires valid boundaries. Simplified: start and end must be in the range 0..length, and start <= end. If you violate this, you will get an error.

That is why an experienced programmer almost never writes substring(...) “straight away”: they first check that the indexes are valid.

5. The “found → checked → cut out” pattern

Now we will assemble everything into one robust pattern and add it to our learning console application. Imagine we are building a simple “User profile” program: the user enters a full name on one line and an email, and we print a neat card. Today we will focus specifically on how to safely get parts of these strings using indexes.

Reality is harsh: people type names however they want. Yes, they put spaces in weird places. Yes, they sometimes forget @. These are not “bad users”—this is “the real world”.

Parsing a full name "First Last" via indexOf(' ')

The simplest full-name variant is two words separated by a space. For now we intentionally do not use split() (that will be in the next lecture), so we will do a “manual cut”: find the space index and cut out the left and right parts.

fun main() {
    val fullName = "Ivan Petrov"
    val space = fullName.indexOf(' ')

    val firstName = if (space == -1) fullName else fullName.substring(0, space)
    val lastName = if (space == -1) "" else fullName.substring(space + 1)

    println(firstName) // Ivan
    println(lastName)  // Petrov
}

Pay attention to space + 1: we do not want the space to end up in the last name. If the space is the last character of the string, substring(space + 1) turns into substring(length), which is just an empty string (and that is normal).

Extracting the domain from an email: indexOf('@') and checks

Email is a classic example of a delimiter-based string. We want to take the domain: everything after @. But we need to protect against cases like "@example.com" and "user@".

fun extractDomain(email: String): String {
    val at = email.indexOf('@')
    if (at == -1) return "INVALID"
    if (at == 0) return "INVALID"
    if (at == email.lastIndex) return "INVALID"

    return email.substring(at + 1)
}

fun main() {
    println(extractDomain("user@example.com")) // example.com
    println(extractDomain("user@"))            // INVALID
}

Here we use email.lastIndex to check: “is @ the last character or not?” And this is exactly the case where lastIndex reads better than length - 1.

Parsing a string in the "key=value" format

A very popular input format is key=value. Today we will do a simple version: find = and cut the string.

fun main() {
    val line = "city=Tokio"
    val eq = line.indexOf('=')

    val key = if (eq == -1) "" else line.substring(0, eq)
    val value = if (eq == -1) "" else line.substring(eq + 1)

    println("key='$key', value='$value'") // key='city', value='Tokio'
}

The pattern is the same: found the index → checked -1 → cut out the left and right parts.

Assembling a “profile card” and not mixing parsing with output

Now let’s assemble a small fragment of our application: we read fullName and email, compute firstName, lastName, domain, and print the card.

fun extractDomain(email: String): String {
    val at = email.indexOf('@')
    if (at <= 0 || at == email.lastIndex) return "INVALID"
    return email.substring(at + 1)
}

fun main() {
    print("Enter first and last name: ")
    val fullName = readln()

    print("Enter email: ")
    val email = readln()

    val space = fullName.indexOf(' ')
    val firstName = if (space == -1) fullName else fullName.substring(0, space)
    val lastName = if (space == -1) "" else fullName.substring(space + 1)

    val domain = extractDomain(email)

    val card = """
        |Profile
        |-------
        |First name: $firstName
        |Last name: $lastName
        |Email domain: $domain
    """.trimMargin()

    println(card)
}

Here the parsing logic (indexes, indexOf(), substring()) is nearby, but it is not mixed into one giant line. This style is easier to debug: if the card looks weird, you can print intermediate values like space, firstName, lastName.

Cheat sheet for indexes and risky spots

When you work with strings, it is useful to keep a small cheat-sheet table in mind: what gets returned and what you must check.

Operation What it returns What to watch to avoid crashing
s.length
number of characters if 0, the string is empty
s.lastIndex
index of the last character will be -1 if the string is empty
s[i]
Char
by index
i
must satisfy
0 <= i < length
s.indexOf(x)
index or -1 -1 must be handled
s.substring(a, b)
piece of the string
a
and
b
in
0..length
, and
a <= b
s.substring(a)
from
a
to the end
a
must be in
0..length

And one more important “bridge” to understanding: zero-based indexes are a general Kotlin principle for indexable structures. It shows up the same way in arrays, in lists, and in loops over indices.

6. Common mistakes when working with indexes and substrings

Mistake #1: confusing length with “the last index”.
A very common slip is to think that if the string length is 6, then the last index is 6. In fact, with zero-based indexing, the last index is always 1 less. This is where “off-by-1” errors come from: you either take the wrong character or crash by going out of bounds.

Mistake #2: doing s[0] without checking for an empty string.
An empty string occurs more often than it seems: the user pressed Enter, you got empty input, something got concatenated into "". If after that you do text[0], the program crashes. The right habit is to check text.isEmpty() first, or at least text.length == 0, and only then touch indexes.

Mistake #3: ignoring indexOf(...) == -1.
indexOf does not “complain”; it honestly returns -1. But if you immediately use that -1 as a boundary in substring(), chaos begins: best case, you get an exception; worst case, you cut the wrong thing and get a “silently wrong” result. That is why the “found → checked → cut out” chain should become a reflex.

Mistake #4: forgetting that substring(start, end) does not include end.
This is a source of especially annoying mistakes: the program seems to work but returns an “almost correct” answer—one character shorter or longer. The best cure is to always say it out loud: “start is inclusive, end is exclusive”, and draw a small diagram on paper when in doubt.

Mistake #5: cutting the right part without removing the delimiter (substring(at) instead of substring(at + 1)).
If you split "user@example.com" by @, you want the right part without @. That means substring(at + 1). If you write substring(at), the domain will start with @, and then validation logic and string comparisons may start behaving oddly. This is especially unpleasant in programs where you later compare the domain to "gmail.com".

Mistake #6: mixing string parsing and result formatting into one huge string.
Technically, you can write one gigantic println("Name: ${...} ..."), but it is almost guaranteed to lead to errors and poor readability. It is more reliable to first compute val firstName, val lastName, val domain, and only then assemble the final output. Such code is easier to read, easier to test, and easier to fix when the user enters something “creative” again.

1
Task
Kotlin SELF, level 9, lesson 3
Locked
String passport
String passport
1
Task
Kotlin SELF, level 9, lesson 3
Locked
Character at index
Character at index
1
Task
Kotlin SELF, level 9, lesson 3
Locked
Delimiter search
Delimiter search
1
Task
Kotlin SELF, level 9, lesson 3
Locked
First and last name
First and last name
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION