Kotlin already has the perfect tool for closed type hierarchies: sealed interfaces with exhaustive when. Storm maps them straight onto a table, so the compiler's exhaustiveness checking extends to your database rows.
A pet table holds cats and dogs with some shared columns and some subtype-specific ones. The code should work with real Cat and Dog types, not a Pet with nullable everything, and adding a new subtype should be impossible to half-do.
Declare the hierarchy exactly as you would in domain code. The sealed interface is the entity; the data classes are the subtypes; @Discriminator marks the column that tells rows apart:
// The sealed interface is the entity; subtypes are plain data classes @Discriminator sealed interface Pet : Entity<Int> data class Cat( @PK val id: Int = 0, val name: String, val indoor: Boolean, ) : Pet data class Dog( @PK val id: Int = 0, val name: String, val weight: Int, ) : Pet
All subtypes share the pet table. Shared fields like name live alongside subtype-specific ones like indoor and weight, which are simply NULL for rows of other subtypes.
The repository works on the sealed interface, and results come back as concrete subtypes. That means when over a result is exhaustive: introduce a Bird and every unhandled branch in the codebase becomes a compile error:
val pets = orm.entity<Pet>() // Reads return the concrete subtypes; when is exhaustive by construction for (pet in pets.findAll()) { when (pet) { is Cat -> render("${pet.name}, indoor=${pet.indoor}") is Dog -> render("${pet.name}, ${pet.weight}kg") } // add a Bird subtype and this stops compiling until handled } // Writes go through the same repository pets.insert(Cat(name = "Bella", indoor = true)) pets.update(Cat(id = 1, name = "Sir Whiskers", indoor = true))
-- one table; the discriminator column picks the subtype on read SELECT p.id, p.dtype, p.name, p.indoor, p.weight FROM pet p -- inserts write only the columns of the concrete subtype INSERT INTO pet (dtype, name, indoor) VALUES ('Cat', ?, ?) INSERT INTO pet (dtype, name, weight) VALUES ('Dog', ?, ?)
On insert and update Storm inspects the runtime class, writes the discriminator, and includes only that subtype's columns. On select it reads the discriminator and constructs the right type. No casting, no manual type column handling.
Shared fields query through the metamodel as usual; subtype refinement is ordinary Kotlin on the results:
// Query the hierarchy like any other entity val indoorCats = orm.entity<Pet>().select() .where(Pet_.name like "B%") .resultList .filterIsInstance<Cat>() .filter { it.indoor }
This page covers the single-table strategy, the default. When subtypes have many disjoint fields, the joined-table strategy (@Polymorphic(JOINED)) puts each subtype's columns in its own table; see Polymorphism for the full decision guide, discriminator customization, and polymorphic foreign keys.
The reference documentation covers the mechanics in depth: