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.
- 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}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
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.