Rasmus Ros opens a recent post on typed schemas in Kotlin with a D&D character sheet. Strength is 1 to 18, Class is Fighter or Wizard or Rogue, Halflings can't be Paladins, Hit Points depend on Class and Constitution. The blank sheet is the schema; a filled-in character is one instance. He spends the rest of the post on a useful question: how do you let library users declare schemas when every user brings their own, and how do call sites read variables back without dissolving into casts and string lookups? His answer is a typed-key design with property delegates on a singleton schema object, very much in the lineage of JetBrains' Exposed. It's a clean enough answer.
It's also an answer to half of the problem his opener describes. "A Halfling can't be a Paladin" is a constraint between fields, as is "Hit Points depends on Class and Constitution." The post solves the typed-fields half and acknowledges, near the end, that constraints between variables are a different design problem deferred to a future post. Schema design has two halves. Programmers usually do the first half to varying degrees, and the second half poorly, if at all.
Stringly to strongly is a spectrum, not a step
The pejorative "stringly typed" is Eric Lippert's, and the pathology is using strings where you should have types: dispatch by string literal, error codes as strings (or signed int, since we're not going to give C a pass here either), lookups that return Object and force a cast. The post's title implies a leap from there to strongly typed. The actual design is a step on a spectrum, where you have:
- String key with cast on read:
map["mean"] as MeanResult. - Typed key with no cast:
map[meanKey]wheremeanKey: StatKey<MeanResult>from his examples. - Direct field access:
record.mean. - Refined types: the type carries a value constraint, the impossible state can't compile.
The post moves one step.
The lookup mechanism is structurally unchanged: still a heterogeneous map indexed by something. The index got typed, which is real progress - you can say "these are the available lookups" - but the keys remain load-bearing, just as structures' variable names are load-bearing. You're just making it where the keys are limited; there's a small functional difference between:
enum LookupKeys {
FIELD1(Double.class),
FIELD2(Integer.class)
}
var data=new HashMap<LookupKeys, Object>() {
/*
* contains a smart get() that casts the
* mapped value to the correct type and
* a smart put() that requires the correct
* type on mutation
*/
}
and...
record data(Double FIELD1, Integer FIELD2)
In both cases, FIELD1 and FIELD2 (what great names!) are constraints on the access, and adding the typecasting for the Map is the "secret sauce"... but if you have the record, why do you need the Map?
The serialization motivation flagged later in the post ("a consumer that doesn't share your Kotlin code can still decode the schema and walk it by name") is the tell: name-walking is a primary use case because the design is a typed key-value store, not a record. You're saving reflection.
Refined types are where the refined library and dependent-type languages live. The strongest parts of that design are visible elsewhere: they became Rust traits, Kotlin extensions on receivers, and Java pattern matching at the language level. Refined types didn't come along because the type system isn't typically strong enough to host them comfortably.
The 6'2" Halfling
Take the example seriously and the question gets sharper. What makes someone a Halfling? There are four - or five, maybe - answers, each a different design commitment.
The first answer is almost laughable: Halfling ban=new Halfling(); - ban is now quite literally a Halfling by definition. There's no other way to refer to ban except as a Halfling.
The next answer is "nominal with validation". The object carries a tag that says Halfling, and there's a constraints check at validation. The 6'2" Halfling is constructible, just invalid. Almost all JVM schema tooling lives here: Jackson, Jakarta Validation, Protobuf, Avro, Exposed. The recurring bugs are validation drift: data that passes the field check and fails the value check, or vice versa. This is ban declared, with a height of Inches.of(74), and one hopes that the height mutator has a validation that says "you can't set this value here."
The next answer is "structural." Membership is "has these fields." A 6'2" object with Halfling-shaped fields is a Halfling. Go interfaces, TypeScript structural types live here. There's a hidden cheat: most structural systems are structural about field presence, not about values. Take structural typing seriously and you collapse into refined typing, because shape-includes-height-as-Halfling-range is part of the shape if you mean it.
Lastly, we come to refined types. Membership is in the type. Inches Refined Range[24, 50] for height; the 6'2" Halfling won't construct. This is the strongest, and the least available; it can be modeled with combinations of our other types, but can be a bear to construct in a generalized sense.
We could, for example, define Halfling with ranged types (the "validation" model) and use a Humanoid base class - and defer specific validation into whatever the Halfling definition describes. But Humanoid has to have a way to define unique characteristics of Halfling (or Paladin) that other Humanoids do not describe - and thus we're back to our original problem.
All four address fields and their values. None easily addresses "A Halfling can't be a Paladin." Class and Species are different fields, the constraint is between them, and that's a different kind of problem.
SemWeb Solved This
OWL has been doing schema-and-constraints as one artifact since its origin, by design. Class hierarchies, multiple superclasses (a Fireman is a Person, a PublicServant, WearsRed, and DrivesTruck, none of which is hard in OWL and most of which Java can't express easily in a general sense without becoming ridiculous - and a Fireman in Java can't become NotAFireman), disjoint classes (Male disjoint from Female, Halfling disjoint from Paladin), property restrictions (hasFather points to a Male, hasMother points to a Female), and reasoners that derive class membership from assertions plus rules.
A Terminator example illustrates it cleanly. You'd declare that T800 is a Terminator, and that Terminator is both a Person and a Robot. Ask the reasoner what classes T800 belongs to and it tells you Person, Robot, and Terminator, by inference, without your having to say so. That is duck typing formalized. Membership is derived, not asserted.
It sharpens further when you split the model from the role. T800 is a primitive class: an individual is a T800 because Skynet built it that way. Terminator is a defined class with necessary and sufficient conditions: wantsToKillHumans=true. Now reprogram one. Pops, in Genisys, is still a T800. Same hardware, same shape... but he no longer wants to kill humans. Is he still a Terminator?
With Terminator as a defined class, the reasoner removes him from it while leaving him in T800, Robot, and Person. The shape didn't change; the values did. Membership shifted accordingly. That is the answer the typed-key design literally cannot reach: category membership as a function of properties plus rules, not of the constructor you called.
The 6'2" Halfling drops cleanly into the same frame. Halfling is a class with a property restriction on height: hasHeight values come from Inches[24, 50]. Assert an individual is a Halfling with height of 74, and the reasoner reports that the ontology is inconsistent: it has a person declared as a Halfling who does not comply with the definition of a Halfling. It does not throw out your data. It does not silently coerce. It tells you your assertions and your rules don't agree, and you decide which to change.
Halflings-can't-be-Paladins is a disjointness axiom. PaladinClass is a disjoint from HalflingRace, applied as a class restriction on Character. Assert that Pippin is a Halfling and a Paladin and the reasoner reports inconsistency, same semantics: your data isn't wrong, your data plus your rules is wrong. (And Professor Tolkien would grab his pipe and assert that Pippin would probably indeed canonically be a Paladin by the end of The Lord of the Rings.) Klause, the rule-handling library Mr. Ros deferred to a future post, is structurally a thing OWL already does.
Why we don't reach for it
If this shape-with-rules is so useful, then why isn't it everywhere? Because of tooling, probably the same reason a lot of fantastic technologies languish.
OWLAPI is verbose and confusing. RDF/XML is effectively unreadable. Turtle is better but most tooling defaults to RDF/XML anyway. Reasoners are slow on large ontologies. There's a giant conceptual barrier: open-world reasoning, IRIs, axioms, the difference between assertion and inference, the tradeoffs between OWL Lite and DL and Full. Most teams that need rules reach for Drools and accept that schema and rules live in different systems with different semantics.
The deeper reason is the programming model. The thing programmers want to write is repo.findById(id). The thing the ontology asks them to write is closer to "fetch the individual at this IRI, project the properties relevant to this query, evaluate against the class restrictions that matter for this operation, and decide what to do if the result is inconsistent." You can wrap that, and SPARQL endpoints sometimes do, but the underlying shift is that there's no canonical Person.
There's an individual data instance that satisfies various class memberships, and the slice of Person-ness you want depends on what you're doing. Programmers don't think that way. Programmers think Person is a class, and it has these fields, and find returns one.
The ontology asks them to hold the operational view and the canonical view at the same time, and the cognitive burden exceeds the benefit for most domains. BCN itself is database-schema-of-record for exactly this reason; an ontology-first version is conceivable, and would have been worse, even though the ontology drove the design and is why the factoids exist as they do.
The actual original intent was to have the factoids be graph nodes - which they are - but articles like this one would be nodes about factoids - so this article itself would have meant creating a set of factoids about OWL, refined types, type systems, and so forth, and the article itself would be one aspect of each of those factoids, and other articles referring to the same topics would be other slices of their representations. Sound like a maze of twisty passages, all alike? That's because it is a maze of twisty passages, all alike, and so BCN ended up being blog-shaped because I wanted to build it in my lifetime.
What works in practice is borrowing the moves where they earn their keep and keeping the surface flat. BCN's provenance system is shaped this way: things belong to a provenance, they're not things in a provenance. The ontology move - identity through relationship, not through field presence - is there, but the scope is tight enough that programmer-mode stays the dominant frame. You attach a tag, you query the tag, the system uses the tag to scope what's visible. Nobody has to ask "what does provenance mean for this query."
The mainstream JVM answer to schema design ends up looking like this: define fields with Protobuf or Avro or Jackson annotations; bolt on validation with Jakarta Validation; bolt on rules, if you need them, with Drools or hand-rolled checks; serialize and pray. The typed-key design from the source post is a clean version of the first layer of that stack. Klause, when it ships, will hopefully be a clean version of the second and third layers in Kotlin DSL form. It will also be reinventing OWL, in Kotlin, without the reasoner, because almost everyone who builds rule systems eventually does.
What this leaves us with
Schema design has two halves. The field half has answers ranging from simple references to string-keyed maps with casts up through refined types, and the JVM mostly lives in the middle of that range. The rule half has answers, mostly outside the JVM mainstream, that few currently working on Kotlin schema libraries seem to be reading. The Halfling-Paladin question has a 22-year-old solution that almost nobody uses because using it never got cheap enough.
It's a worthwhile effort, to be fair: most effort is. But there's a reason we keep searching for something when it's already been built multiple times: what's been built to be complete just hasn't been easy enough to use, or it's been easy enough to use without being complete.
There's always a tradeoff, and we make choices based on what we can do.