Techbase Academy
Android · Lesson 13

Generics, Objects and Extensions

14 phasesMarket Stall ToolkitAndroid Studio
Phase 1 of 14
Lesson Introduction

Welcome to the lesson where Kotlin starts to feel like a real professional toolbox. You already know how to write functions, classes and lambdas. Now you will learn four techniques that working Android developers use every single day:

  • Generics, which let one class or function work safely with many different types.
  • Singleton and companion objects, which give you exactly one shared instance of something.
  • Extension functions and properties, which let you add new abilities to types you did not write.
  • Scope functions (let, run, with, apply, also), which make setup and null-handling code short and readable.

Picture a school's lost-and-found box. A box does not care whether it holds a water bottle, a calculator or a jacket. It just holds something, and when you take the item out you get back the same kind of thing you put in. That is a generic container. Kotlin's List<String> and List<Int> are the same idea. You have already been using generics without knowing it.

By the end of this lesson you will be able to:

  • Write your own generic classes and generic functions with type parameters such as T.
  • Add a constraint to a type parameter so it only accepts suitable types.
  • Create a singleton with the object keyword.
  • Use a companion object for shared constants and factory functions.
  • Write extension functions and extension properties, and explain their limits.
  • Choose the right scope function for the job.
  • Combine all of these in a small quiz engine.

How this fits the bigger picture: This lesson matches the Generics, objects, and extensions pathway from Unit 3 of Google's official Android Basics with Compose course. That pathway builds a quiz-style app step by step. We build our own original quiz engine and add more practice. The official link is in the resources at the end.

Why this matters for Android

  • ViewModel, StateFlow, List and Result are all generic. You will read generic signatures like MutableStateFlow<UiState> for the rest of this course.
  • Libraries add extension functions to Android types. Writing modifier.padding(16.dp) is an extension function call, and 16.dp is an extension property on Int.
  • Many Android databases, settings holders and loggers are singletons.
  • Scope functions such as apply appear in almost every piece of setup code you will read.
✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 14
Prerequisite Concepts

1. Classes, properties and methods (lesson 11)

You should know how to declare class Student(val name: String), create an instance and call its functions. You should also recall data class, enum class, interface and override.

2. Function types and lambdas (lesson 12)

Scope functions take a lambda as their last parameter, so trailing lambda syntax and it should feel familiar. If { it * 2 } looks strange, revisit lesson 12 first.

3. Nullability (lesson 10)

You should be comfortable with String?, the safe call ?. and the fact that Kotlin refuses to let you call a method on a possibly-null value without checking.

4. Types and inference (lesson 07)

Kotlin usually works out types for you (val age = 24 is an Int). In this lesson you will see the compiler infer types in more places, including inside angle brackets.

✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 14
Conceptual Understanding: Generics

The problem generics solve

Suppose you need a box that holds one item. Without generics you might write one class per type:

kotlin
class IntBox(val item: Int)
class StringBox(val item: String)
class BooleanBox(val item: Boolean)
// ... and one for every other type, forever

That is repetition, and repetition breeds bugs. Another idea is to use the type Any (Kotlin's "anything" type). But then, when you take the item out, you get an Any, and you must guess what it really is. Guessing wrongly crashes the app.

Generics give you the third option: write the class once, leaving a blank for the type. The blank is called a type parameter, and by convention it is a single capital letter:

Letter Usually means
T Type (the general-purpose choice)
E Element (inside collections)
K, V Key and Value (in maps)
R Result
text
  class Box<T>(val item: T)
            |
            +-- a blank for the type, filled in when you use it

  Box<Int>(5)           the blank becomes Int
  Box<String>("shoes")  the blank becomes String

Kotlin fills the blank in for you when it can: Box(5) is automatically a Box<Int>.

Constraints: limiting what the blank may be

Sometimes "any type at all" is too loose. A function that finds the bigger of two things must be able to compare them, and not every type can be compared. You can put a limit on the blank with a colon:

text
  <T : Comparable<T>>      T must be something that can be compared with itself
  <T : Priced>             T must be something that implements the Priced interface

This is called an upper bound or constraint. It is a promise to the compiler: "whatever T turns out to be, it has at least these abilities". In return, inside the function you may use those abilities.

Analogy: A generic container is a lunch box. A constraint is a label on it: "hot food only". Now you know the item will survive a microwave, so you can safely heat the box without checking what is inside.

A short word on in and out

You may see List<out E> or Comparable<in T> in documentation. These markers control whether, for example, a List<String> may be used where a List<Any> is expected. Reading a list of strings as a list of "anythings" is safe, which is why List is declared with out. You do not need to write these yourself yet. Just recognise them when you meet them.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Generics. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 14
Worked Examples: Generics

Example 1: A generic class

Input (code):

kotlin
class Box<T>(val item: T) {
 fun describe(): String = "Box holds $item"
}
fun main() {
 val numberBox = Box(5)
 val wordBox = Box("shoes")
 val flagBox = Box(true)
 println(numberBox.describe())
 println(wordBox.describe())
 println(flagBox.describe())
}

Expected output:

Box holds 5
Box holds shoes
Box holds true

Explanation: One class definition, three different types. Kotlin infers Box<Int>, Box<String> and Box<Boolean>. Because the compiler knows numberBox.item is an Int, writing numberBox.item + 1 works, and writing wordBox.item + 1 is a compile error.

Example 2: A generic function

Type parameters for functions go before the function name.

Input (code):

kotlin
fun <T> repeatItem(item: T, times: Int): String {
 return List(times) { item }.joinToString(", ")
}
fun main() {
 println(repeatItem("Hi", 3))
 println(repeatItem(7, 2))
}

Expected output:

Hi, Hi, Hi
7, 7

Explanation: List(times) { item } builds a list that contains item the given number of times (lists are covered fully in lesson 14), and joinToString(", ") glues the elements into one string. The function never needs to know what T is.

Example 3: A constraint with Comparable

Input (code):

kotlin
fun <T : Comparable<T>> biggest(a: T, b: T): T {
 return if (a > b) a else b
}
fun main() {
 println(biggest(3, 9))
 println(biggest("apple", "mango"))
 println(biggest(2.5, 1.5))
}

Expected output:

9
mango
2.5

Explanation: The > operator needs compareTo, which every Comparable type has. Int, String and Double are all comparable, so all three calls compile. Strings compare alphabetically, and "mango" sorts after "apple". If you remove the constraint and write plain <T>, the line a > b becomes a compile error, because Kotlin cannot promise that an arbitrary T can be compared.

Example 4: A constraint with your own interface

Input (code):

kotlin
interface Priced {
 val price: Int
}
data class Shirt(override val price: Int) : Priced
data class Shoes(override val price: Int) : Priced
fun <T : Priced> totalPrice(first: T, second: T): Int {
 return first.price + second.price
}
fun main() {
 println(totalPrice(Shirt(3000), Shirt(4500)))
 println(totalPrice(Shoes(12000), Shoes(8000)))
}

Expected output:

7500
20000

Explanation: Inside totalPrice you can read .price because the constraint guarantees it exists. Both arguments must be the same T, so totalPrice(Shirt(1), Shoes(2)) would not compile. If you wanted to allow mixing, you would write the parameters as Priced instead of T.

✏️ Your Task
Practise what you just learned about Worked Examples: Generics. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 14
Conceptual Understanding: Objects

Singletons with object

Normally you create many instances of a class. Sometimes you want exactly one: a single settings holder, a single logger, a single scoreboard. That pattern is called a singleton, and Kotlin builds it into the language. Replace class with object and you are done:

kotlin
object AppConfig {
 const val APP_NAME = "Techbase Quiz"
 var launchCount = 0
 fun recordLaunch() {
 launchCount++
 }
}

There is no constructor and you never write AppConfig(). You use the name directly, and Kotlin creates the one and only instance the first time it is needed.

Companion objects

A companion object is an object that lives inside a class and belongs to the class itself rather than to any one instance. If you have used static in Java, a companion object fills that role. It is the right home for constants and factory functions (functions that create instances).

text
  Instance members              Companion members
  (one per object)              (one per class)
  ---------------------         -----------------------
  student.name                  Student.create("Ada")
  student.greet()               Student.count

Which one do I use?

You want Use
One global thing with no instances at all object
A constant or helper that belongs to a class companion object
A constant known at compile time (number or string) const val inside an object or companion
Many instances A normal class
✏️ Your Task
Practise what you just learned about Conceptual Understanding: Objects. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 14
Worked Examples: Objects

Example 5: A singleton in action

Input (code):

kotlin
object AppConfig {
 const val APP_NAME = "Techbase Quiz"
 var launchCount = 0
 fun recordLaunch() {
 launchCount++
 }
}
fun main() {
 println(AppConfig.APP_NAME)
 AppConfig.recordLaunch()
 AppConfig.recordLaunch()
 println("Launches: ${AppConfig.launchCount}")
}

Expected output:

Techbase Quiz
Launches: 2

Explanation: Both calls to recordLaunch() changed the same object, which is why the count reached 2. const val marks a value fixed at compile time. It is only allowed on String and the basic number and boolean types, and only at the top level or inside an object or companion object.

Example 6: A companion object and a factory function

Input (code):

kotlin
class Student private constructor(val name: String) {
 companion object {
 const val SCHOOL = "Techbase Academy"
 var created = 0
 fun create(name: String): Student {
 created++
 return Student(name)
 }
 }
 fun introduce() {
 println("$name attends $SCHOOL")
 }
}
fun main() {
 val ada = Student.create("Ada")
 val bola = Student.create("Bola")
 ada.introduce()
 bola.introduce()
 println("Students created: ${Student.created}")
}

Expected output:

Ada attends Techbase Academy
Bola attends Techbase Academy
Students created: 2

Explanation: The constructor is private, so outside code cannot write Student("Ada"). The only way in is Student.create(...), which also keeps count. A companion object can call the private constructor because it is inside the class. This is a common way to force every instance to be created in a controlled way.

✏️ Your Task
Practise what you just learned about Worked Examples: Objects. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 14
Conceptual Understanding: Extensions

Adding abilities to types you do not own

You cannot open Kotlin's String class and add a function to it. But you can write an extension function, which looks like a member of the class when you call it. The recipe is to put the type you are extending in front of the function name, followed by a dot:

text
  fun  String . shout (): String = ...
       |        |
       |        +-- the new function's name
       +-- the "receiver type": the thing being extended

Inside the extension, the keyword this means the value the function was called on (and you can often leave this out).

An extension property is the same idea for properties. It cannot store data (there is no hidden field), so it must have a getter that computes its value.

The rules and limits

  • An extension cannot access private members of the class it extends. It is only a tidy way of calling public things.
  • If the class already has a member function with the same name and parameters, the member wins. Extensions never override.
  • Extensions are resolved by the declared type at compile time, not changed by inheritance at run time.
  • They are not magic: "hi".shout() compiles to an ordinary function call shout("hi").

Analogy: Giving a friend a phone case with a card holder does not change the phone. It just lets you do something extra with it that feels built in.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Extensions. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 14
Worked Examples: Extensions

Example 7: Extension functions

Input (code):

kotlin
fun String.shout(): String = this.uppercase() + "!"
fun Int.isEven(): Boolean = this % 2 == 0
fun Int.toNaira(): String = "₦$this"
fun main() {
 println("hello".shout())
 println(4.isEven())
 println(7.isEven())
 println(1500.toNaira())
}

Expected output:

HELLO!
true
false
₦1500

Explanation: Each function is declared outside any class, yet is called like a built-in. In toNaira, this is the Int the function was called on (1500), so the string template builds ₦1500.

Example 8: Extension properties

Input (code):

kotlin
val String.vowelCount: Int
 get() = count { it in "aeiouAEIOU" }
val Int.squared: Int
 get() = this * this
fun main() {
 println("Ibadan".vowelCount)
 println("Rhythm".vowelCount)
 println(7.squared)
}

Expected output:

3
0
49

Explanation: "Ibadan" contains the vowels I, a and a, so the count is 3. "Rhythm" has none among aeiou, so 0. The count { ... } call runs the lambda on each character and counts the ones for which it returns true. Notice that extension properties use get() instead of = value, because they cannot hold a value of their own.

Example 9: Extending a generic type

Extensions and generics combine well. This one works on a list of any element type:

Input (code):

kotlin
fun <T> List<T>.secondOrNull(): T? {
 return if (this.size >= 2) this[1] else null
}
fun main() {
 println(listOf("a", "b", "c").secondOrNull())
 println(listOf(10).secondOrNull())
}

Expected output:

b
null

Explanation: The <T> before List<T> declares the type parameter that the extension uses. The return type is T? because there might be no second element. The built-in function getOrNull(1) does the same job, but writing it yourself shows the pieces working together.

✏️ Your Task
Practise what you just learned about Worked Examples: Extensions. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 14
Conceptual Understanding: Scope Functions

Five helpers, one idea

The Kotlin standard library has five small functions that run a block of code "in the scope of" an object: let, run, with, apply and also. They do the same basic job, but they differ in two ways:

  1. How the object is referred to inside the block: as it, or as this (which means you can often just write property names).
  2. What the whole call returns: the lambda's last value, or the object itself.
Function Object inside is Returns Typical use
let it the lambda result run code only if a value is not null; transform a value
run this the lambda result compute something using the object's members
with(obj) this the lambda result several calls on one object (not an extension, you pass the object in)
apply this the object itself configure an object right after creating it
also it the object itself a side effect such as logging, without changing the chain

A memory trick: apply and also hand the object back (apply it, then give it back). let, run and with hand back whatever the block produced. And it goes with let and also, while this goes with run, with and apply. Keep the table open until it becomes habit. Nobody memorises it on the first day.

Do not overdo it. Scope functions are optional. If a plain val and two lines are clearer, use those. Nesting several scope functions inside each other usually makes code harder to read.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Scope Functions. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 14
Worked Examples: Scope Functions

Example 10: let for null handling

Input (code):

kotlin
fun main() {
 val name: String? = "tunde"
 val nothing: String? = null
 name?.let { println("Hello ${it.uppercase()}") }
 nothing?.let { println("This never prints") }
 println("Done")
}

Expected output:

Hello TUNDE
Done

Explanation: With the safe call ?.let, the block runs only when the value is not null, and inside it it is a non-null String. The second let is skipped completely. This replaces a lot of if (x != null) blocks.

Example 11: apply to configure an object

Input (code):

kotlin
class Profile {
 var name = ""
 var city = ""
 var age = 0
}
fun main() {
 val profile = Profile().apply {
 name = "Ada"
 city = "Ibadan"
 age = 20
 }
 println("${profile.name}, ${profile.city}, ${profile.age}")
}

Expected output:

Ada, Ibadan, 20

Explanation: Inside apply, this is the new Profile, so name = "Ada" really means this.name = "Ada". apply then returns the profile itself, which is why it can be assigned straight to val profile. Without apply you would have to write profile.name = ... three times.

Example 12: also, run and with

Input (code):

kotlin
class Profile {
 var name = ""
 var city = ""
 var age = 0
}
fun main() {
 val sum = (2 + 3).also { println("Sum is $it") }
 val profile = Profile().apply {
 name = "Ada"
 city = "Ibadan"
 age = 20
 }
 val summary = profile.run { "$name lives in $city" }
 val line = with(profile) { "$name is $age" }
 println(sum)
 println(summary)
 println(line)
}

Expected output:

Sum is 5
5
Ada lives in Ibadan
Ada is 20

Explanation: also ran its side effect (the print), then handed back the original 5, so sum is 5. run and with both return the last expression in the block, here a string. The difference between them is only how you write it: profile.run { ... } versus with(profile) { ... }.

✏️ Your Task
Practise what you just learned about Worked Examples: Scope Functions. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 14
Step-by-Step Build: A Generic Quiz Engine

Now we combine everything into one small program, a tiny version of the quiz idea from the official pathway. It has a generic Question<T> (answers can be text, numbers or true/false), an enum for difficulty, a singleton scoreboard and an extension function that checks answers.

Step 1: The data

kotlin
enum class Difficulty { EASY, MEDIUM, HARD }
data class Question<T>(
 val text: String,
 val answer: T,
 val difficulty: Difficulty
)

Question<String> holds a text answer, Question<Int> a number, Question<Boolean> a yes/no. One class covers all three, and the compiler checks the type of every answer you supply.

Step 2: A singleton scoreboard

kotlin
object Scoreboard {
 var points = 0
 private set
 fun award(difficulty: Difficulty) {
 points += when (difficulty) {
 Difficulty.EASY -> 1
 Difficulty.MEDIUM -> 2
 Difficulty.HARD -> 3
 }
 }
}

private set means any code can read points, but only the object itself can change it. That protects the score from accidental edits. The when over an enum must cover every case, and because it is used as an expression, Kotlin checks that you did.

Step 3: An extension on a generic type

kotlin
fun <T> Question<T>.check(given: T): Boolean {
 val correct = given == answer
 if (correct) Scoreboard.award(difficulty)
 val result = if (correct) "correct" else "wrong, answer was $answer"
 println("$text -> $result")
 return correct
}

check is added to every Question<T>. It compares the given answer to the real one, awards points on success and prints a result. Inside the function we can read answer, text and difficulty directly, because this is the question.

Step 4: Run it

kotlin
fun main() {
 val capital = Question("Capital of Nigeria?", "Abuja", Difficulty.EASY)
 val states = Question("How many states does Nigeria have, not counting the FCT?", 36, Difficulty.MEDIUM)
 val typed = Question("Is Kotlin statically typed?", true, Difficulty.MEDIUM)
 capital.check("Abuja")
 states.check(35)
 typed.check(true)
 println("Points: ${Scoreboard.points}")
}

Expected output:

Capital of Nigeria? -> correct
How many states does Nigeria have, not counting the FCT? -> wrong, answer was 36
Is Kotlin statically typed? -> correct
Points: 3

Working it out: The first answer is right and is worth 1 point. The second is wrong (35, not 36), so no points. The third is right and MEDIUM is worth 2. The total is 1 + 2 = 3. Try states.check("36"): it will not compile, because states is a Question<Int> and a String is not an Int. That compile error is generics protecting you.

✏️ Your Task
Practise what you just learned about Step-by-Step Build: A Generic Quiz Engine. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 14
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: A generic wrapper

Objective: Write a generic function and a constrained generic function.

Steps:

  1. Write fun <T> wrap(item: T): String that returns the item inside square brackets.
  2. Print wrap(5), wrap("a") and wrap(2.5).
  3. Write fun <T : Comparable<T>> smallest(a: T, b: T): T and print smallest(8, 3) and smallest("zebra", "ant").
kotlin
fun <T> wrap(item: T): String = "[$item]"
fun <T : Comparable<T>> smallest(a: T, b: T): T {
 return if (a < b) a else b
}
fun main() {
 println(wrap(5))
 println(wrap("a"))
 println(wrap(2.5))
 println(smallest(8, 3))
 println(smallest("zebra", "ant"))
}

Expected output:

[5]
[a]
[2.5]
3
ant

"ant" comes before "zebra" alphabetically, so it is the smaller string.

🎯 Your Challenge

Exercise 2: A ticket machine

Create a singleton TicketMachine with a private set property lastTicket starting at 0 and a function next(): Int that increases it by one and returns it. Then create a class Bus with a companion object holding const val OPERATOR = "Ibadan Express". Print the operator, then take three tickets and print each.

kotlin
object TicketMachine {
 var lastTicket = 0
 private set
 fun next(): Int {
 lastTicket++
 return lastTicket
 }
}
class Bus {
 companion object {
 const val OPERATOR = "Ibadan Express"
 }
}
fun main() {
 println(Bus.OPERATOR)
 println("Ticket ${TicketMachine.next()}")
 println("Ticket ${TicketMachine.next()}")
 println("Ticket ${TicketMachine.next()}")
}

Expected output:

Ibadan Express
Ticket 1
Ticket 2
Ticket 3

Because TicketMachine is a singleton, the counter is shared by every caller and never repeats a number.

🎯 Your Challenge

Exercise 3: Extensions and scope functions together

  1. Write an extension function String.isPalindrome(): Boolean that ignores letter case. Test "Level", "Kayak" and "Ibadan".
  2. Write an extension property val Int.cubed: Int and print 3.cubed.
  3. Create a class Settings with var theme = "light" and var fontSize = 14. Build one using apply so the theme is "dark" and the font size is 18, then print it using with.
kotlin
fun String.isPalindrome(): Boolean {
 val cleaned = this.lowercase()
 return cleaned == cleaned.reversed()
}
val Int.cubed: Int
 get() = this * this * this
class Settings {
 var theme = "light"
 var fontSize = 14
}
fun main() {
 println("Level".isPalindrome())
 println("Kayak".isPalindrome())
 println("Ibadan".isPalindrome())
 println(3.cubed)
 val settings = Settings().apply {
 theme = "dark"
 fontSize = 18
 }
 with(settings) {
 println("Theme: $theme, size: $fontSize")
 }
}

Expected output:

true
true
false
27
Theme: dark, size: 18

"Level" becomes "level", which reads the same backwards. "ibadan" reversed is "nadabi", which is different.

✏️ Your Task

Write an extension function fun Double.toNairaString(): String that returns "₦" followed by the number. Use ?.let on a nullable Double? price so that the converted text prints only when the price is not null. Test it once with a value and once with null.

Phase 13 of 14
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: Using a generic type without a constraint, then calling a method on it. The wrong code: fun <T> bigger(a: T, b: T) = if (a > b) a else b. The problem: Kotlin cannot promise that T supports >. The fix: add the bound, <T : Comparable<T>>.

Mistake #2: Using Any when you meant a generic. The wrong code: class Box(val item: Any). The problem: when you read item back it is just Any, and wrong guesses crash at run time. The fix: class Box<T>(val item: T) keeps the exact type.

Mistake #3: Trying to create an instance of an object. The wrong code: val c = AppConfig(). The problem: an object has no constructor, because it already is the single instance. The fix: use it by name: AppConfig.recordLaunch().

Mistake #4: Forgetting that a companion lives on the class. The wrong code: val s = Student("x"); s.created. The problem: companion members belong to the class, not to instances. The fix: Student.created.

Mistake #5: const val with a non-constant value. The wrong code: const val NOW = System.currentTimeMillis(). The problem: const values must be known when the code is compiled. The fix: use plain val, which can be computed at run time.

Mistake #6: Expecting an extension to reach private members. The wrong code: an extension that reads a private val of the class. The fix: extensions only see what any outside code sees. If you need the data, expose it properly in the class.

Mistake #7: Expecting an extension to replace a member. The problem: if String already has a function called shout() with the same parameters (it does not, but imagine), your extension would never be called, since members always win. The fix: choose a different name.

Mistake #8: Mixing up apply and let. The wrong code: val p = Profile().let { name = "Ada" } where name is a property of Profile. The problem: let gives you it, not this, so the bare name is unresolved. And let returns the lambda result, so p would not be a Profile anyway. The fix: use apply for configuring, or write it.name = "Ada" if you really want let.

Mistake #9: Giving an extension property a stored value. The wrong code: val Int.doubled: Int = this * 2. The problem: extension properties cannot have backing fields or initialisers. The fix: val Int.doubled: Int get() = this * 2.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 14
Knowledge Check

Answer each question in your head (or on paper), then reveal the answer.

Question 1. What does the T in class Box<T> stand for, and when is it filled in?

T is a type parameter, a blank for a type. It is filled in when you create or use the class, for example Box<String>("x"), or automatically by type inference as in Box(5).

Question 2. Why does fun <T> bigger(a: T, b: T) = if (a > b) a else b not compile, and how do you fix it?

Because an unconstrained T might not support comparison. Add an upper bound: fun <T : Comparable<T>> bigger(a: T, b: T): T.

Question 3. What is the difference between an object and a companion object?

An object is a standalone singleton with one instance. A companion object is declared inside a class and holds members (constants, factory functions) that belong to the class itself rather than to its instances.

Question 4. Which scope function is best for configuring a new object, and what does it return?

apply. Inside the block the object is this, so you can set properties by name, and apply returns the object itself.

Question 5. Can an extension function read a private property of the class it extends? What if the class already has a member with the same name?

No, extensions can only use public members. And if the class has a member with the same signature, the member always wins over the extension.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Market Stall Toolkit

Project goal: Build a small shop toolkit for a market stall, using a constrained generic class, a singleton for shop settings, extension functions and scope functions.

Requirements:

  1. Declare interface Priced { val price: Int } and data class Item(val name: String, override val price: Int) : Priced.
  2. Write a generic class Crate<T : Priced>(val contents: T, val quantity: Int) with a function total(): Int that returns the item price times the quantity.
  3. Write an extension function fun Int.toNaira(): String returning the number with a ₦ in front.
  4. Create object ShopSettings with const val SHOP_NAME = "Mama Tola Stores" and var discountPercent = 10.
  5. Write two extension functions on Crate<T> (for any T : Priced): summary() returning text like 2 x ₦45000 = ₦90000, and discountedTotal() which subtracts discountPercent percent from the total.
  6. In main, print a welcome line, then summaries for 2 bags of rice at 45000 and 1 bag of beans at 38000.
  7. Print the discounted rice total, change the discount to 20 using apply, and print it again.
  8. Use ?.let with a nullable coupon code to print a message only when the code exists. Test with a real code and with null.

Stretch goal: Add a companion object to Item with a factory function fun free(name: String): Item that creates an item priced at 0, and print a Crate of three free samples.

Main.kt
interface Priced {
 val price: Int
}
data class Item(val name: String, override val price: Int) : Priced {
 companion object {
 fun free(name: String): Item = Item(name, 0)
 }
}
class Crate<T : Priced>(val contents: T, val quantity: Int) {
 fun total(): Int = contents.price * quantity
}
fun Int.toNaira(): String = "₦$this"
object ShopSettings {
 const val SHOP_NAME = "Mama Tola Stores"
 var discountPercent = 10
}
fun <T : Priced> Crate<T>.summary(): String {
 return "$quantity x ${contents.price.toNaira()} = ${total().toNaira()}"
}
fun <T : Priced> Crate<T>.discountedTotal(): Int {
 return total() - total() * ShopSettings.discountPercent / 100
}
fun main() {
 println("Welcome to ${ShopSettings.SHOP_NAME}")
 val rice = Crate(Item("Rice bag", 45000), 2)
 val beans = Crate(Item("Beans bag", 38000), 1)
 println(rice.summary())
 println(beans.summary())
 println("Rice after ${ShopSettings.discountPercent}% off: ${rice.discountedTotal().toNaira()}")
 ShopSettings.apply { discountPercent = 20 }
 println("Rice after ${ShopSettings.discountPercent}% off: ${rice.discountedTotal().toNaira()}")
 val coupon: String? = "WELCOME"
 val missing: String? = null
 coupon?.let { println("Coupon $it applied") }
 missing?.let { println("This never prints") }
 val samples = Crate(Item.free("Pepper sample"), 3)
 println(samples.summary())
}

Expected output:

Welcome to Mama Tola Stores
2 x ₦45000 = ₦90000
1 x ₦38000 = ₦38000
Rice after 10% off: ₦81000
Rice after 20% off: ₦72000
Coupon WELCOME applied
3 x ₦0 = ₦0

How it works: The rice total is 45000 * 2 = 90000. A 10 percent discount is 90000 * 10 / 100 = 9000, leaving 81000. After the discount changes to 20, the reduction is 18000, leaving 72000. The missing?.let line prints nothing because the value is null. The Crate class only accepts types that implement Priced, so Crate("hello", 2) would be rejected by the compiler.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 13 project”.

3

On github.com click New repository, name it android-lesson-13-generics-objects-and-extensions, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 13 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: Generics
  • Worked Examples: Generics
  • Conceptual Understanding: Objects
  • Worked Examples: Objects
  • Conceptual Understanding: Extensions
  • Worked Examples: Extensions
  • Conceptual Understanding: Scope Functions
  • Worked Examples: Scope Functions
  • Step-by-Step Build: A Generic Quiz Engine
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close