Class Types Workbook (Part 2)

Ten short exercises on Kotlin's specialized classes — data classes, enums, sealed hierarchies, objects and companions, inner classes, and value classes.

Practice problems for One Keyword Does the Work. Each takes a minute or two. Some exercises auto-check: implement the function (or the class its method uses) and press Run — hidden tests go green when you’re right and red (with a hint) when you’re not, all on JetBrains’ Kotlin compiler server. The pure declaration exercises stay attempt-then-reveal: fill in the TODO, press Run to compile it, then click Show answer. Nothing here is a trick question, just direct practice of the syntax from the lesson.

data classes

1. A data class

Declare a data class User(val id: Int, val name: String).

Try it — edit, then press Run // TODO: make this a data class class User(val id: Int, val name: String) fun main() { val u = User(1, "Ada") println(u) // a data class gives a readable toString() }
Show answer Hide answer
data class User(val id: Int, val name: String)

2. Copy with a change

Implement renamed so it returns a copy of the user with the same id but the name "Ada L.".

Implement it, then press Run to check import org.junit.Test import org.junit.Assert data class User(val id: Int, val name: String) class Test { @Test fun copiesWithName() { Assert.assertEquals("keep the id, change the name", User(1, "Ada L."), renamed(User(1, "Ada"))) } } //sampleStart fun renamed(u: User): User = u // TODO: return a copy with the name "Ada L." //sampleEnd
Show answer Hide answer
fun renamed(u: User) = u.copy(name = "Ada L.")

3. Value equality for free

Declare data class User(val id: Int, val name: String), then write the expression comparing two separate User(1, "Ada") instances for value equality.

Try it — edit, then press Run data class User(val id: Int, val name: String) fun main() { // TODO: compare two separate User(1, "Ada") instances for value equality val equal = false println(equal) }
Show answer Hide answer
data class User(val id: Int, val name: String)

User(1, "Ada") == User(1, "Ada")   // true — generated equals compares fields

enum, sealed, object

4. A small enum

Declare enum class Direction { NORTH, SOUTH, EAST, WEST }.

Try it — edit, then press Run // TODO: give Direction all four constants: NORTH, SOUTH, EAST, WEST enum class Direction { NORTH } fun main() { println(Direction.NORTH) // then reference SOUTH, EAST, WEST too }
Show answer Hide answer
enum class Direction { NORTH, SOUTH, EAST, WEST }

5. A sealed hierarchy

Declare a sealed Result with a Success(val data: String) and an Error(val message: String) subtype.

Try it — edit, then press Run // TODO: make Result sealed, with Success(val data: String) and Error(val message: String) subtypes class Result fun main() { val r = Result() // replace with Result.Success("ok") once the subtypes exist println(r) }
Show answer Hide answer
sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
}

6. Exhaustive when on a sealed type

Implement describe with a when over a Result that returns the data on success and the message on error — with no else.

Implement it, then press Run to check import org.junit.Test import org.junit.Assert sealed class Result { data class Success(val data: String) : Result() data class Error(val message: String) : Result() } class Test { @Test fun describes() { Assert.assertEquals("return the data on success", "ok", describe(Result.Success("ok"))) Assert.assertEquals("return the message on error", "boom", describe(Result.Error("boom"))) } } //sampleStart fun describe(r: Result): String { // TODO: return r.data on Success, r.message on Error — a when with no else return "TODO" } //sampleEnd
Show answer Hide answer
fun describe(r: Result) = when (r) {
    is Result.Success -> r.data
    is Result.Error -> r.message
}

The compiler knows the hierarchy is closed, so the when is exhaustive without an else.

7. A singleton

Declare a singleton object Registry with a mutable list entries and an add(s: String) method.

Try it — edit, then press Run // TODO: make Registry a singleton 'object' and add an add(s: String) method class Registry { val entries = mutableListOf<String>() } fun main() { val r = Registry() // an 'object' wouldn't need this — use Registry directly println(r.entries) }
Show answer Hide answer
object Registry {
    val entries = mutableListOf<String>()
    fun add(s: String) { entries.add(s) }
}

8. A companion factory

Give class Config private constructor(val raw: String) a companion object with a fromText(text: String): Config factory.

Try it — edit, then press Run // TODO: add a companion object with a fromText(text: String): Config factory class Config private constructor(val raw: String) { companion object { } } fun main() { // once the factory exists: val c = Config.fromText("hi"); println(c.raw) println("build a Config via Config.fromText(...)") }
Show answer Hide answer
class Config private constructor(val raw: String) {
    companion object {
        fun fromText(text: String) = Config(text)
    }
}

Config.fromText("...")

inner and value classes

9. An inner class

Outer holds a val name. Add an inner class Printer whose show() prints Outer’s name.

Try it — edit, then press Run // TODO: add an inner class Printer whose show() prints the outer 'name' class Outer(val name: String) fun main() { val o = Outer("Ada") println(o.name) // then: o.Printer().show() }
Show answer Hide answer
class Outer(val name: String) {
    inner class Printer {
        fun show() = println(name)
    }
}

inner lets the nested class reach the outer instance’s members; a plain nested class cannot.

10. A value class

Declare a value class UserId(val raw: Int) so an id can’t be mixed up with a plain Int.

Try it — edit, then press Run // TODO: make UserId a value class (annotate with @JvmInline) wrapping a single Int class UserId(val raw: Int) fun main() { val id = UserId(7) println(id.raw) }
Show answer Hide answer
@JvmInline
value class UserId(val raw: Int)

A value class wraps a single value for type safety with no runtime allocation.


Going deeper: copy, finality, and value classes

12. Copy with a change

Use copy to return a new User with a different name, leaving the original untouched.

Implement it, then press Run to check import org.junit.Test import org.junit.Assert data class User(val id: Int, val name: String) class Test { @Test fun renamed() { Assert.assertEquals(User(1, "Grace"), renamed(User(1, "Ada"), "Grace")) } } //sampleStart fun renamed(u: User, newName: String): User = u // TODO: return a copy with name = newName //sampleEnd
Show answer Hide answer
fun renamed(u: User, newName: String) = u.copy(name = newName)

copy duplicates the object with the named fields changed. Remember it’s shallow — referenced objects are shared, not cloned.

13. Why won’t it extend?

You try open data class User(...) so you can subclass it, and the compiler refuses. Why? Reveal to check.

Show answer Hide answer

A data class is final and cannot be made open — the error is “this type is final, so it cannot be extended.” An inheritable data class would make equals symmetry impossible to guarantee, so Kotlin forbids it. Data classes model values, and values don’t have subclasses.

14. When does a value class stop being free?

A @JvmInline value class Email(val raw: String) is normally inlined to a bare String at runtime. Name two situations where the compiler has to box it into a real object instead. Reveal to check.

Show answer Hide answer

When it’s used as a nullable (Email?), as a generic type argument (List<Email>), or where a supertype is expected. In those positions erasure forces a real wrapper — the same pressure that boxes Int into Integer. A value class is free in the direct path, not everywhere.


Back to the lesson, One Keyword Does the Work, or on to the next one: enums up close.

Comments