LearnAI ToolsCareerPractice BuildsPlayContact
KotlinIntermediate~2 hours

Inventory Tracker

Model products with data classes, filter and sort stock levels, and report low-stock items.

Data ClassesCollectionsSealed Classes

Overview

An inventory system needs more than a raw stock count — it needs a small, fixed set of *meaningful categories* a product can fall into: fully stocked, running low, or completely out. You could represent that with an `enum class`, and that would work for the labels alone, but a `sealed class` lets each state carry its own extra data, and it earns something an `enum` cannot give you: when you `when`-match over a `sealed class` without an `else` branch, the Kotlin compiler checks at compile time that every possible subtype has been handled, and refuses to compile if you add a new state later and forget to handle it somewhere.

By the end of this tutorial you will have a console application built around a `Product` data class, Kotlin's collection operations (`filter`, `sortedBy`, `map`) to slice and order the inventory, and a `sealed class StockStatus` with `InStock`, `LowStock`, and `OutOfStock` variants classified by an exhaustive `when` expression. That combination — immutable data classes plus a sealed hierarchy for "one of a few known states" — is one of the most distinctly Kotlin ways to model a domain, and it generalizes to almost any system with a small number of well-defined states: order status, connection state, or, as here, stock level.

What You'll Build
  • A `data class Product(id, name, quantity, reorderLevel)` modeling one inventory item.
  • A `sealed class StockStatus` with `InStock`, `LowStock`, and `OutOfStock` variants.
  • A `statusFor(product: Product)` function that classifies a product with an exhaustive `when`, no `else` needed.
  • Collection-based queries: `lowStockItems()` via `filter`, and a sorted report via `sortedBy`.
  • An `InventoryTracker` class owning the product list and generating a full stock report.
  • A report that renders each `StockStatus` variant with its own message via another exhaustive `when`.

Prerequisites

  • Data classes — constructor properties and the generated `equals()`/`toString()`/`copy()`.
  • Collection operations — `filter`, `sortedBy`, `map`, and chaining them together.
  • Sealed classes — declaring a `sealed class` with a small number of subtypes, and why that differs from a plain `enum class`.
  • The `when` expression, including how the compiler enforces exhaustiveness over a `sealed class` with no `else`.
  • Function basics and lambda syntax (`{ it.field }`).

Project Structure

The whole program lives in a single file, `InventoryTracker.kt`, containing `Product`, the `StockStatus` sealed class hierarchy, a `statusFor()` classification function, an `InventoryTracker` class that owns the product list, and `fun main()`. `Product` never knows about `StockStatus` — the classification logic lives entirely in `statusFor()`, kept separate from the data it classifies, the same separation of "data" from "behavior over that data" used in the grading project's `gradeFor()`. `InventoryTracker` is the layer that owns the `List<Product>` and turns raw products into a rendered report using both `statusFor()` and the collection operations from Step 4.

Step 1: Define the Product Data Class

Every field is a `val`, making `Product` immutable — once created, a product's quantity never changes in place; instead, restocking a product later means producing a *new* `Product` with `copy(quantity = newQuantity)`, which the generated `copy()` function makes trivial. Immutability here is deliberate: a `Product` inside a `List<Product>` can never be silently mutated by code elsewhere that happens to hold the same reference, which removes a whole category of bugs by construction.

// All vals: Product is immutable by design. Restocking never mutates a
// Product in place — it produces a new one via copy(quantity = ...), so no
// code holding a reference to a Product can have it change out from under it.
data class Product(
val id: Int, // Unique identifier for this product
val name: String, // Product name
val quantity: Int, // Units currently on hand
val reorderLevel: Int // Threshold below which the product counts as "low stock"
)

Step 2: Model Stock Status With a Sealed Class

`sealed class StockStatus` declares a closed hierarchy: every subtype of `StockStatus` must be defined in this same file, so the compiler knows the *complete* list of possibilities and can verify a `when` over them is exhaustive. `InStock` and `LowStock` are declared as `data class` subtypes because each needs to carry its own `quantity` (and `LowStock` also a `reorderLevel`), while `OutOfStock` carries no extra data at all, so it is declared as a Kotlin `object` — a singleton, since every out-of-stock product needs no distinguishing state beyond the fact that it is out of stock.

// sealed class = a closed set of subtypes, all declared right here. The
// compiler uses that closed set to verify a when-over-StockStatus handles
// every case — critical for catching a missed branch if a new state is added.
sealed class StockStatus {
data class InStock(val quantity: Int) : StockStatus() // Carries its own quantity for display
data class LowStock(val quantity: Int, val reorderLevel: Int) : StockStatus() // Carries both figures for the report
object OutOfStock : StockStatus() // No extra data needed: a singleton
}

Step 3: Classify a Product With an Exhaustive when

`statusFor()` is a single-expression function whose body is a `when` with no `else` branch at all — and it still compiles, because the compiler can see `StockStatus` is `sealed` and can prove `InStock`, `LowStock`, and `OutOfStock` are the only three possibilities. If a fourth variant, say `Discontinued`, were ever added to `StockStatus` in Step 2, this `when` would immediately fail to compile until a matching branch were added here too — that is the real payoff of `sealed class` over a plain `enum`.

fun statusFor(product: Product): StockStatus = when {
product.quantity <= 0 -> StockStatus.OutOfStock
product.quantity <= product.reorderLevel -> StockStatus.LowStock(product.quantity, product.reorderLevel)
else -> StockStatus.InStock(product.quantity)
// No "else" is required here for exhaustiveness over StockStatus's subtypes — this when { } uses boolean
// conditions instead of matching on a subject, so it needs its own else as a boolean fallback, which is
// different from an exhaustiveness check; a when(status) { is InStock -> ...; is LowStock -> ...; ... }
// matching directly on a StockStatus value is the one the compiler checks for exhaustiveness, as used next.
}

Step 4: Filter and Sort the Inventory

`lowStockItems()` uses `filter { }` with a lambda predicate to keep only products whose computed `StockStatus` is not `InStock`, using the `is` operator to check a sealed subtype the same way `instanceof` would in Java. `sortedByQuantity()` uses `sortedBy { it.quantity }`, which returns a *new* list in ascending order without mutating the original — consistent with `Product` being immutable, sorting never has a reason to touch the source list at all.

fun lowStockItems(products: List<Product>): List<Product> {
// filter{} keeps only elements where the lambda returns true; "is" checks a sealed subtype like Java's instanceof
return products.filter { statusFor(it) !is StockStatus.InStock }
}
fun sortedByQuantity(products: List<Product>): List<Product> {
return products.sortedBy { it.quantity } // Returns a new List sorted ascending; the original list is untouched
}
Example Usage

Click Run to see what this code prints.

Step 5: Build the InventoryTracker and Report

`InventoryTracker` owns the `List<Product>` and produces a formatted report line for every product, using a second `when` — this time matching directly on the `StockStatus` value itself with `is InStock ->`/`is LowStock ->`/`OutOfStock ->` branches, which is the version the compiler genuinely checks for exhaustiveness. Each branch destructures the sealed subtype's own properties (`status.quantity`, `status.reorderLevel`) directly, which only type-checks because the compiler has smart-cast `status` to that specific subtype inside its branch.

class InventoryTracker(private val products: List<Product>) {
fun report(): List<String> {
return products.map { product -> // map{} transforms each Product into one report line
val status = statusFor(product)
val message = when (status) { // Matches directly on StockStatus: exhaustive, no else needed
is StockStatus.InStock -> "In stock (${status.quantity} units)"
is StockStatus.LowStock -> "Low stock! ${status.quantity} left (reorder at ${status.reorderLevel})"
StockStatus.OutOfStock -> "Out of stock" // object subtype: matched by name, no "is" needed
}
"${product.name}: $message"
}
}
fun lowStock(): List<Product> = lowStockItems(products) // Reuses Step 4's standalone function
}

Step 6: Build the Program Entry Point

`main()` builds a small hard-coded catalog spanning all three stock states, wraps it in an `InventoryTracker`, and prints both the full report from Step 5 and the low-stock-only list from Step 4, sorted by quantity ascending so the most urgent items appear first.

fun main() {
val catalog = listOf(
Product(1, "USB Cable", 40, 10),
Product(2, "Wireless Mouse", 3, 5),
Product(3, "Keyboard", 0, 5),
Product(4, "Monitor Stand", 12, 4),
)
val tracker = InventoryTracker(catalog)
println("===== INVENTORY REPORT =====")
for (line in tracker.report()) {
println(line)
}
println("\n===== LOW STOCK (sorted, most urgent first) =====")
for (product in sortedByQuantity(tracker.lowStock())) {
println("${product.name}: ${product.quantity} left")
}
}

Complete Code

Here is the full program with every declaration assembled in the correct order, ready to save as `InventoryTracker.kt` and run with `kotlinc InventoryTracker.kt -include-runtime -d inventory.jar && java -jar inventory.jar`.

data class Product(
val id: Int,
val name: String,
val quantity: Int,
val reorderLevel: Int
)
sealed class StockStatus {
data class InStock(val quantity: Int) : StockStatus()
data class LowStock(val quantity: Int, val reorderLevel: Int) : StockStatus()
object OutOfStock : StockStatus()
}
fun statusFor(product: Product): StockStatus = when {
product.quantity <= 0 -> StockStatus.OutOfStock
product.quantity <= product.reorderLevel -> StockStatus.LowStock(product.quantity, product.reorderLevel)
else -> StockStatus.InStock(product.quantity)
}
fun lowStockItems(products: List<Product>): List<Product> {
return products.filter { statusFor(it) !is StockStatus.InStock }
}
fun sortedByQuantity(products: List<Product>): List<Product> {
return products.sortedBy { it.quantity }
}
class InventoryTracker(private val products: List<Product>) {
fun report(): List<String> {
return products.map { product ->
val status = statusFor(product)
val message = when (status) {
is StockStatus.InStock -> "In stock (${status.quantity} units)"
is StockStatus.LowStock -> "Low stock! ${status.quantity} left (reorder at ${status.reorderLevel})"
StockStatus.OutOfStock -> "Out of stock"
}
"${product.name}: $message"
}
}
fun lowStock(): List<Product> = lowStockItems(products)
}
fun main() {
val catalog = listOf(
Product(1, "USB Cable", 40, 10),
Product(2, "Wireless Mouse", 3, 5),
Product(3, "Keyboard", 0, 5),
Product(4, "Monitor Stand", 12, 4),
)
val tracker = InventoryTracker(catalog)
println("===== INVENTORY REPORT =====")
for (line in tracker.report()) {
println(line)
}
println("\n===== LOW STOCK (sorted, most urgent first) =====")
for (product in sortedByQuantity(tracker.lowStock())) {
println("${product.name}: ${product.quantity} left")
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a `Discontinued` object subtype to `StockStatus` and watch the compiler flag every `when` in the file that needs a new branch.
  • Add a `restock(product: Product, amount: Int): Product` function that returns `product.copy(quantity = product.quantity + amount)`, keeping `Product` immutable end to end.
  • Add a `category: String` field to `Product` and a `groupBy { it.category }` report that prints stock status per category.
  • Write a `totalValue(products: List<Product>, priceLookup: (Int) -> Double): Double` using `sumOf { it.quantity * priceLookup(it.id) }`.
  • Persist the catalog to a CSV file and reload it on startup, mapping each line back into a `Product`.

Summary

You built an inventory tracker where `Product` stays a small, immutable data class, and `StockStatus` uses a `sealed class` to make "in stock, low stock, or out of stock" a closed set the compiler actively enforces — try adding a fourth state and every exhaustive `when` in the file will refuse to compile until you handle it. Combined with `filter`, `sortedBy`, and `map`, this is the same shape — a sealed hierarchy for a domain's finite states, paired with collection operations for querying it — that you will reach for anywhere a Kotlin program needs to model "one of a few known outcomes" safely.