# Magical Kotlin: Building a Type-Safe Validation DSL
Table of Contents
Kotlin Is Getting Tricky
Scala has the reputation for complexity on the JVM. Kotlin is usually seen as the pragmatic, safe, slightly boring alternative. I think that is changing. With a handful of recent features — context parameters, definitely-non-null types, contracts — Kotlin can be just as clever, and just as fun to abuse.
To show how these compose, I built a small validation library called sure.
It is Kotlin Multiplatform, and the public API reads like this:
@Validatabledata class Address(val city: String, val zip: String) { companion object { val validator = Validator<Address> { field(::city) { notBlank() } field(::zip) { lengthIn(5..5) } } }}
@Validatabledata class User(val name: String, val age: Int, val address: Address) { companion object { val validator = Validator<User> { field(::name) { notBlank(); lengthIn(1..50) } field(::age) { inRange(0..150) } validated(::address) // reuses Address's own validator, resolved by type } }}
User("alice", 30, Address("Kraków", "30001")).validate() // ValidationResult.ValidNo reflection on user types, no string field names — and that validate() isn’t hand-written.
A KSP processor generates it at compile time; everything between here and there is how the pieces it relies on
get built.
A Note on Valiktor
What I built looks a lot like Valiktor. I found it after I’d written most of this. Valiktor predates context parameters and definitely-non-null types, so its API carries more boilerplate.
Setup
Everything below uses Kotlin 2.4.0.
I’ll build the library from scratch — one feature at a time, starting from the simplest function that works and pulling in a new mechanism only when the previous step’s pain forces it, until we reach the final API. The same example types ride through every step:
data class Address(val city: String, val zip: String)data class User(val name: String, val age: Int, val address: Address)Step 1: The hand-rolled version
The simplest thing that could work — one function:
fun validateUser(user: User): List<String> { val errors = mutableListOf<String>()
when { user.name.isBlank() -> errors += "name: must not be blank" user.name.length !in 1..50 -> errors += "name: must be 1..50 characters" } if (user.age !in 0..150) errors += "age: must be in 0..150"
if (user.address.city.isBlank()) errors += "address.city: must not be blank" if (user.address.zip.length != 5) errors += "address.zip: must be 5 digits"
return errors}It works, but it hurts.
The error list is hand-threaded through every branch.
Field names are strings.
Each rule re-states the field it talks about.
And the nested Address doesn’t compose — every check on it re-states the address. prefix by hand, and a second level
of nesting would mean another prefix on top, with nothing to stop a typo in either.
Step 2: A scope to hold the errors
The first pain is the most basic: the error list is a local variable I pass around by hand.
I’ll move it into an object that travels with the validation — a scope.
The scope holds the value being checked and owns the error list, so rules just call raise:
class ValidationScope<T>(val value: T) { val errors: List<String> field = mutableListOf() fun raise(message: String) { errors += message }}That field = mutableListOf() under the property is not a typo — it’s an explicit backing field, stable as of
Kotlin 2.4 (experimental behind -Xexplicit-backing-fields in 2.3). It lets a val declare two types: a public one and
the one the field actually holds.
Here the public type is List<String>, but inside the class the name errors resolves to the MutableList behind it —
so callers get a read-only view while raise still appends, with no “private _errors plus public getter erorrs”
template.
To run rules against a scope, I take a lambda with the scope as its receiver — that lambda is the validator:
class Validator<T>(private val rules: ValidationScope<T>.() -> Unit) { fun validate(value: T): List<String> { val scope = ValidationScope(value) scope.rules() return scope.errors }}The call site already reads better — no list to declare, no list to return:
val userValidator = Validator<User> { if (value.name.isBlank()) raise("name: must not be blank") if (value.age !in 0..150) raise("age: must be in 0..150") if (value.address.city.isBlank()) raise("address.city: must not be blank") if (value.address.zip.length != 5) raise("address.zip: must be 5 digits")}
userValidator.validate(User("", 200, Address("Kraków", "30")))// [name: must not be blank, age: must be in 0..150, address.zip: must be 5 digits]rules: ValidationScope<T>.() -> Unit is a function with receiver: inside the braces, this is the scope, so value
and raise resolve unqualified.
Still, the messages are strings and I’m reaching into value.name while re-typing "name:" by hand.
That duplication is the next pain.
Step 3: KProperty0 — name the field once
"name" (the label) and value.name (the read) are the same field spelled twice.
I want to name it once.
Kotlin has bound property references for exactly this: user::name is a KProperty0<String> that carries both the
property’s name and a zero-argument get().
To use them, the rules block hands its value back to me, and the scope grows a parent link and a path so a field can descend into a child scope that still reports into the same error list:
class Validator<T>(private val rules: ValidationScope<T>.(T) -> Unit) { fun validate(value: T): List<String> { val scope = ValidationScope(value) scope.rules(value) return scope.errors }}
class ValidationScope<T>( val value: T, private val parent: ValidationScope<*>? = null, val path: String = "",) { val errors: MutableList<String> = parent?.errors ?: mutableListOf() fun raise(message: String) { errors += if (path.isEmpty()) message else "$path: $message" }}field takes the property reference, reads it once, and runs the block against a child scope:
fun <F> ValidationScope<*>.field(property: KProperty0<F>, block: ValidationScope<F>.(F) -> Unit) { val childPath = if (path.isEmpty()) property.name else "$path.${property.name}" val child = ValidationScope(property.get(), this, childPath) child.block(property.get())}The leaf checks are extensions on the scope they apply to, so notBlank() exists only on a ValidationScope<String>:
fun ValidationScope<String>.notBlank() { if (value.isBlank()) raise("must not be blank")}
fun <T : Comparable<T>> ValidationScope<T>.inRange(range: ClosedRange<T>) { if (value !in range) raise("must be in $range")}The field name is written once now, as a bound reference:
val userValidator = Validator<User> { user -> field(user::name) { notBlank() } field(user::age) { inRange(0..150) } field(user::address) { address -> field(address::city) { notBlank() } field(address::zip) { notBlank() } }}Better — but two things still grate.
The block has to name its value (user ->) and every reference repeats it (user::name, user::age).
And each check is welded to one receiver: field, notBlank, inRange are all extensions on ValidationScope, so a
function can require exactly one scope and nothing more.
Step 4: Context parameters — drop the receiver juggling
I’d rather write field(::name) than field(user::name), and lose the user ->.
For ::name to resolve, this inside the block has to be the User — but the block still has to reach the scope, to
raise.
That’s two receivers at once, and an ordinary T.() -> Unit only gives you one.
Carrying an implicit dependency like this is one of the things context parameters are good for. A context parameter is a dependency a function declares without making it the receiver. The rules block becomes an extension on the value, with a scope available in context:
class Validator<T>(private val rules: context(ValidationScope<T>) T.() -> Unit) { fun validate(value: T): List<String> { val scope = ValidationScope(value) rules(scope, value) return scope.errors }}Now this is the User, so ::name resolves, while the ValidationScope<User> rides along in context — no user ->.
field declares the scope it needs as a context parameter instead of an extension receiver:
context(scope: ValidationScope<*>)fun <F> field(property: KProperty0<F>, block: ValidationScope<F>.(F) -> Unit) { val childPath = if (scope.path.isEmpty()) property.name else "${scope.path}.${property.name}" val child = ValidationScope(property.get(), scope, childPath) child.block(property.get())}The checks move from receiver to context too, sharing one check helper. Note the predicate flags the failure: when
it returns true, the value is bad and check raises — so each leaf describes what’s wrong, not what’s allowed.
context(scope: ValidationScope<T>)fun <T> check(predicate: (T) -> Boolean, onError: (T) -> String) { if (predicate(scope.value)) scope.raise(onError(scope.value)) // predicate true → raise}
context(_: ValidationScope<String>)fun notBlank() = check<String>({ it.isBlank() }) { "must not be blank" }
context(_: ValidationScope<String>)fun lengthIn(range: IntRange) = check<String>({ it.length !in range }) { "must be $range characters" }
context(_: ValidationScope<T>)fun <T : Comparable<T>> inRange(range: ClosedRange<T>) = check({ it !in range }) { "must be in $range" }The top-level call site loses both the user -> and the repeated receiver — only a nested field still names its value
(address ->), because a field block hands the value in as its lambda argument rather than as this:
val userValidator = Validator<User> { field(::name) { notBlank() } field(::age) { inRange(0..150) } field(::address) { address -> field(address::city) { notBlank() } field(address::zip) { lengthIn(5..5) } }}The one trap worth calling out: a context parameter is not a second this. If you remember the old context
receivers, those did add a second receiver — context parameters don’t. Inside Validator<User> { … } there’s a
single this (the User), so ::name resolves against it, while the scope just rides along:
| Inside… | the single this is… | the scope is… |
|---|---|---|
Validator<User> { … } | the User | an ambient context(ValidationScope<User>) parameter — not a this |
field(::name) { … } | the ValidationScope<String> | the receiver itself (field’s block is ValidationScope<F>.(F)) |
You can’t call the scope’s members directly — raise("…") won’t resolve in the outer block, and there’s no
this@ValidationScope label. Its only job is to satisfy other functions that ask for one: when notBlank() declares
context(_: ValidationScope<String>), the compiler finds the nearest matching value and wires it in. It’s resolution,
not dispatch, and at the JVM level it lowers to an ordinary leading argument. A function can also ask for several
context parameters at once — which is what lets a field block stay a plain extension on the scope while the value
arrives as the lambda argument.
Step 5: Nesting — the sealed scope family and the value contract
Stacking field already nests one object in another — address.city works because each call extends the parent’s path
with .name.
The real test is collections, where errors must still report a sensible path — tags[2], headers[Accept] — but each
kind of descent builds that path differently.
A single ValidationScope class can’t express “append .name” vs “append [index]” vs “append [key]” cleanly, so I
split it into a small two-tier family.
The base declares the contract; one intermediate owns the error list, the other forwards errors to its parent; and a
leaf per nesting kind only has to say how it builds its path.
@ValidationDslsealed class ValidationScope<out T> { internal abstract val path: String internal abstract fun addError(error: ValidationError)
fun raise(message: String) = addError(ValidationError(path, message))}
// owns the error list — RootScope, and the throwaway EphemeralScope from Step 8sealed class ParentScope<out T> : ValidationScope<T>() { val errors: List<ValidationError> field = mutableListOf() final override fun addError(error: ValidationError) { errors += error }}
// no list of its own — forwards every error up to its parentsealed class ChildrenScope<out T> : ValidationScope<T>() { abstract val parent: ValidationScope<*> final override fun addError(error: ValidationError) = parent.addError(error)}
internal class RootScope<out T>(val value: T) : ParentScope<T>() { override val path = ""}
internal class FieldScope<out T>( val value: T, name: String, override val parent: ValidationScope<*>,) : ChildrenScope<T>() { override val path = if (parent.path.isEmpty()) name else "${parent.path}.$name"}
internal class ItemScope<out T>( val value: T, index: Int, override val parent: ValidationScope<*>,) : ChildrenScope<T>() { override val path = "${parent.path}[$index]"}
internal class EntryScope<out T>( val value: T, key: Any?, override val parent: ValidationScope<*>,) : ChildrenScope<T>() { override val path = "${parent.path}[$key]"}Two intermediate classes carry all the plumbing.
ParentScope owns the list — behind the explicit backing field from Step 2, so addError appends internally while
callers only read — and ChildrenScope forwards addError to its parent, so every nested error bubbles up to the one
RootScope at the top.
A leaf then declares only its path and holds its value; raise lives once, on the base.
Both intermediates and the base are sealed, which matters for the accessor next.
Errors are now a small type instead of a bare string, so the path rides along (this becomes a sealed type with Field/
Element/Root cases in Step 11):
data class ValidationError(val path: String, val message: String)The value contract
Start with the payoff, because the syntax below only makes sense once you know what it buys.
Picture a user validating a nullable field — optional(::nickname) { … } where nickname: String?. Inside that block
I want to call notBlank(), but notBlank() only exists on ValidationScope<String>, not ValidationScope<String?>.
Without help I’d be writing value!! or value?.let { … } at every single check — exactly the null-noise this library
exists to delete. What I want instead: one null check at the top of the block, and the compiler treats the value as a
plain String for the rest — no !!, no ?, no cast.
A contract delivers that. It’s a promise the getter makes to the compiler, stated in the contract { } block:
when this getter returns a non-null value, treat the receiver as a scope of the non-null type — written
returnsNotNull() implies (this@value is ValidationScope<T & Any>). That T & Any is a definitely-non-null type:
“the non-null version of an unbounded generic T” — for nullable T = X? it’s X, for already-non-null T it
collapses to T.
@OptIn(ExperimentalContracts::class)val <T> ValidationScope<T>.value: T get() { contract { returnsNotNull() implies (this@value is ValidationScope<T & Any>) }
return when (this) { is RootScope -> value is FieldScope -> value is ItemScope -> value is EntryScope -> value is EphemeralScope -> value } }Why an extension and not a member?
The base scope owns no value — each leaf declares its own — so as a member, value would have to be abstract on
ValidationScope. And Kotlin forbids contracts on abstract (or open) declarations: a contract { } describes one
concrete body, but an abstract member has none, and an open one could be overridden out from under its promise. An
extension is neither — it’s a single final function with a real body, the exhaustive when that reconstructs value
from whichever leaf this happens to be. That’s the form a contract { } is allowed on, and its receiver parameter is
what returnsNotNull() implies (this@value is ValidationScope<T & Any>) smart-casts.
In Step 7 this same contract lets one helper handle nullable and non-nullable fields with no cast.
The collection combinators each spin up the matching scope, so a deep failure still reports the right path:
context(scope: ValidationScope<List<T>>)inline fun <T : Any> eachItem(rule: ValidationScope<T>.(T) -> Unit) { scope.value.forEachIndexed { index, item -> ItemScope(item, index, scope).rule(item) }}
context(scope: ValidationScope<Map<K, V>>)inline fun <K, V : Any> eachValue(rule: ValidationScope<V>.(V) -> Unit) { for ((k, v) in scope.value) EntryScope(v, k, scope).rule(v)}A failure inside eachItem now reports tags[2]: must not be blank, because ItemScope built that path from its
parent’s.
Step 6: @DslMarker — closing the leak
Now that scopes nest, the DSL has a quiet bug.
Inside a nested block, this is the inner scope — but the enclosing scope is still in lexical reach, and both expose
raise, addError, and friends.
A member meant for the outer scope can silently resolve there from an inner block, attaching an error at the wrong path,
with no error from the compiler.
@DslMarker is the fence against this.
Annotate the scope type and the compiler forbids an implicit call that would skip past the nearest receiver to an
outer one of the same marker:
@DslMarkerannotation class ValidationDslThat’s the annotation already sitting on ValidationScope back in Step 5.
After it, reaching an outer scope from a nested block is a compile error unless you qualify it explicitly (
this@Validator.raise(...)).
You only ever see the innermost scope by default — which is what you wanted.
Step 7: Nullable fields — field vs optional
So far field assumed a non-null property.
Real DTOs have nullable fields, with two sensible behaviors: skip if null (optional), or fail if null (a required
field that happens to be nullable).
The definitely-non-null type from Step 5 carries the whole signature:
context(scope: ValidationScope<*>)inline fun <F : Any> field( property: KProperty0<F>, block: ValidationScope<F>.(F) -> Unit = {},) { val value = property.get() FieldScope(value, property.name, scope).block(value)}
context(scope: ValidationScope<*>)inline fun <F : Any> optional( property: KProperty0<F?>, block: ValidationScope<F>.(F) -> Unit,) { val value = property.get() if (value != null) { FieldScope(value, property.name, scope).block(value) }}optional takes a KProperty0<F?> and, inside the null check, the value narrows to F.
field constrains its block to F : Any, so even on a nullable property the rules see a non-null value.
No casts anywhere — F & Any does the narrowing in the type, the Step 5 contract does it on the value.
Step 8: Combinators that need a private scope — anyOf / not
Some combinators don’t want their inner errors recorded — they only care whether the inner rules passed.
anyOf succeeds if any branch is clean; not succeeds if its rule fails.
Both run rules against a throwaway scope whose errors never reach the parent.
This is the fifth scope, and it falls straight out of the Step 5 split: it owns its errors rather than forwarding
them, so it’s a ParentScope, and that’s the entire definition — the list, the backing field, and addError are all
inherited.
internal class EphemeralScope<out T>( val value: T, parent: ValidationScope<*>,) : ParentScope<T>() { override val path = parent.path}
context(scope: ValidationScope<T>)fun <T> anyOf(vararg rules: ValidationScope<T>.() -> Unit, message: () -> String) { val anyValid = rules.any { rule -> val isolated = EphemeralScope(scope.value, scope) isolated.rule() isolated.errors.isEmpty() } if (!anyValid) scope.raise(message())}
context(scope: ValidationScope<T>)fun <T> not(rule: ValidationScope<T>.() -> Unit, message: () -> String) { val isolated = EphemeralScope(scope.value, scope) isolated.rule() if (isolated.errors.isEmpty()) scope.raise(message()) // rule passed → negation fails}EphemeralScope keeps its own list instead of delegating up, so the parent never sees the trial-run errors.
The rule language is done: nesting with correct paths, @DslMarker safety, optional/field for
nullables, and anyOf/not. Every pain from the hand-rolled Step 1 function is now gone.
The remaining steps change register. Steps 1–8 fixed pains; from here on the rules are settled and the work is
dressing them in a public API — make Validator findable by type, translate messages, and finally generate
validate(). Same library, outer layer.
Step 9: The Validator class — inline, reified, noinline
Validator has been a thin wrapper.
I want three things from it: construct it as Validator<User> { … }, look it up later by type, and validate an
arbitrary value.
Looking up by type means storing T::class, which means the type must survive to runtime — reified — and that forces
the constructing function inline:
open class Validator<T>( protected val kClass: KClass<T & Any>, internal val applyRules: ValidationScope<T>.() -> Unit,) { companion object { inline operator fun <reified T : Any> invoke( noinline rules: context(ValidationScope<T>) T.() -> Unit, ): Validator<T> = Validator(T::class) { rules(value) } }}Three deliberate keywords:
reified Tkeeps the type at runtime, soT::classis a realKClassfor the registry — and that’s what forcesinline.noinline rulesis the counterweight: the lambda is stored in theValidator, so it must exist as a real object in bytecode, and inlined lambdas don’t — their bodies are copied into the call site with nothing left to store.- The rules type
context(ValidationScope<T>) T.() -> Unitis the same context-plus-receiver shape from Step 4, now at the top level —thisis the value, the scope is in context.
Three smaller choices in the signature are worth a line each:
KClass<T & Any>, notKClass<T>. The class leavesTunbounded so it can wrap a nullable target, butKClass’s own parameter isAny-bounded — there’s noKClassof a nullable type.T::classalready produces aKClass<T & Any>, so the definitely-non-null projection is the only thing that fits the field.operator fun invokeon the companion, instead of a plain constructor. Building aValidatorneedsT::class, which needsreified, which needsinline— and a constructor can be none of those. So the real entry point has to be an inline reified factory function. Naming itinvokeon the companion keeps the constructor-like call site:Validator<User> { … }resolves toValidator.Companion.invoke<User>(…), so nothing at the call site has to change.openclass with anopen fun validate.@Validatable(with = …)(Step 12) lets a caller register a customValidatorsubclass in place of the default, and that’s only possible if the class can be extended andvalidateoverridden — afinalclass would slam the door.
Step 10: Validating an Any — the isInstanceOf contract
The point of the registry is to validate a value you only know as Any?.
After a runtime type check I want to use it as T without an unchecked cast — and a contract buys that:
@OptIn(ExperimentalContracts::class)private fun <T : Any> Any.isInstanceOf(kClass: KClass<T>): Boolean { contract { returns(true) implies (this@isInstanceOf is T) } return kClass.isInstance(this)}
open fun validate(value: Any?): ValidationResult = when { value == null -> ValidationResult.Invalid(listOf(ValidationError("", "must not be null"))) !value.isInstanceOf(kClass) -> ValidationResult.Invalid(listOf(ValidationError("", "expected ${kClass.simpleName}"))) else -> { val scope = RootScope(value) // value smart-cast to T here applyRules(scope) if (scope.errors.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(scope.errors) }}returns(true) implies (this is T) reads alarming — it’s an unverified assertion, meaning the compiler can’t prove it
and takes the claim on faith, then smart-casts value to T with no as T and no warning. What makes it safe rather
than reckless is the function body: KClass.isInstance performs the real runtime type check, so by the time the
assertion is “trusted” it has already been verified at runtime. The contract just teaches the compiler what that
Boolean result means.
ValidationResult is just Valid or Invalid(errors) — a sealed result type that replaces the raw List once there’s
a type mismatch to report.
Step 11: Structured, translatable messages — fun interface
The messages have been bare strings this whole time.
Errors shouldn’t hard-code English, so I replace the string with a Message carrying a stable key, pre-stringified
args, and a default text:
data class Message(val key: String, val args: List<String> = emptyList(), val text: String = key) { fun render(translator: Translator? = null): String = translator?.translate(key, args) ?: text
companion object { val NotBlank = Message("validation.notBlank", text = "must not be blank") fun LengthIn(range: IntRange) = Message("validation.lengthIn", listOf(range.toString()), "must be $range characters") // … }}The translator is a single-method interface, so I declare it fun interface and any caller can hand it a lambda:
fun interface Translator { fun translate(key: String, args: List<String>): String?}
val pl = Translator { key, args -> catalogue[key]?.format(args) }error.message.render(pl) // a lambda where an interface is expectedrender falls back to text when no translator resolves the key — useful out of the box, localizable when you need it.
The ValidationError from Step 5 grows into a sealed type whose cases carry a Message and a path:
sealed interface ValidationError { val message: Message
data class Field(val path: String, override val message: Message) : ValidationError data class Element(val path: String, val index: Int, override val message: Message) : ValidationError data class Root(override val message: Message) : ValidationError}This is the one place the Step 5 split shows a seam.
With a single ValidationError(path, message), raise could live once on the base.
Now the case depends on the scope — Root for root and ephemeral scopes, Field for a field or map entry,
Element (with its index) for a list item — so raise(message: Message) goes back to abstract on the base, and each
leaf builds its own:
abstract fun raise(message: Message) // on ValidationScope, replacing the String version
// RootScope, EphemeralScopeoverride fun raise(message: Message) = raise(ValidationError.Root(message))
// FieldScope, EntryScopeoverride fun raise(message: Message) = raise(ValidationError.Field(path, message))
// ItemScopeoverride fun raise(message: Message) = raise(ValidationError.Element(parent.path, index, message))Every check now returns a Message instead of a String; the rest of the structure is untouched.
Step 12: @Validatable + KSP — generating validate()
One pain is left: calling someValidator.validate(req) by hand and wiring up a registry.
The shape I’m after is the one from the very top of this post — tag a class @Validatable, point it at its validator,
and get a validate() extension for free:
@Validatabledata class Address(val city: String, val zip: String) { companion object { val validator = Validator<Address> { field(::city) { notBlank() } field(::zip) { lengthIn(5..5) } } }}
@Validatabledata class User(val name: String, val age: Int, val address: Address) { companion object { val validator = Validator<User> { field(::name) { notBlank(); lengthIn(1..50) } field(::age) { inRange(0..150) } validated(::address) // Address's validator, looked up by type } }}
User("alice", 30, Address("Kraków", "30001")).validate() // ValidationResult — nothing hand-wiredThe annotation itself is tiny.
It marks the class and, optionally, names an external validator object instead of the companion validator:
@Target(AnnotationTarget.CLASS)annotation class Validatable(val with: KClass<out Validator<*>> = Validator::class)The shape is deliberately the same as @Serializable from kotlinx.serialization: tag a class, and a compile-time
processor emits the boilerplate you’d otherwise hand-write — there encodeToString/decodeFromString wiring, here a
validate() extension. Same ergonomics, same “no reflection, no runtime cost” promise; the only difference is that
kotlinx.serialization ships a full compiler plugin while this is a few hundred lines of KSP.
Turning that @Validatable into a real validate() is a compile-time job,
and KSP — Kotlin Symbol Processing — is the tool for it.
It’s the lightweight successor to kapt: instead of generating Java stubs and running a javac annotation processor,
KSP hands you a resolved view of the program’s Kotlin symbols (classes, functions, properties, annotations) and lets
you emit new source files, which the compiler then picks up in the same build.
No reflection, no runtime cost, no stub round-trip — the generated validate() is as if you’d typed it.
The processor isn’t annotated or imported anywhere — KSP discovers it through a META-INF/services entry, a plain
service-loader file naming the provider class:
sure.ksp.ValidationExtensionProcessorProviderThe provider’s only job is to hand KSP a SymbolProcessor, wired with the two services every processor leans on: a
CodeGenerator to emit files and a KSPLogger to report problems back through the compiler.
class ValidationExtensionProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = ValidationExtensionProcessor(environment.codeGenerator, environment.logger)}One detail sets the tone for the whole processor: it works on strings KSP resolves for it, never on the sure types
directly — the KSP module doesn’t even depend on the runtime one. So the fully-qualified names it cares about are just
constants:
private const val ANNOTATION_FQN = "sure.Validatable"private const val VALIDATOR_FQN = "sure.Validator"private const val VALIDATION_SCOPE_FQN = "sure.ValidationScope"private const val VALIDATION_RESULT_FQN = "sure.ValidationResult"private const val GENERATED_PACKAGE = "sure"private const val GENERATED_FILE = "GeneratedValidationExtensions"private const val VALIDATOR_FIELD = "validator"private const val WITH_ARG = "with"process is the workhorse. KSP runs in rounds — it calls process again whenever a round generated new symbols that
themselves need processing — so a one-shot generator latches a generated flag and bails on re-entry. It asks the
Resolver for every class carrying @Validatable, turns each into a small ValidatorRef, and writes one file:
private class ValidationExtensionProcessor( private val codeGenerator: CodeGenerator, private val logger: KSPLogger,) : SymbolProcessor { private var generated = false
override fun process(resolver: Resolver): List<KSAnnotated> { if (generated) return emptyList()
val classes = resolver.getSymbolsWithAnnotation(ANNOTATION_FQN, false) .filterIsInstance<KSClassDeclaration>() .toList() if (classes.isEmpty()) return emptyList()
val refs = classes.mapNotNull { it.toValidatorRef() } if (refs.isEmpty()) return emptyList()
val originatingFiles = classes.mapNotNull { it.containingFile }.distinct() codeGenerator.createNewFile( Dependencies(false, *originatingFiles.toTypedArray()), GENERATED_PACKAGE, GENERATED_FILE, ).use { stream -> OutputStreamWriter(stream).use { it.write(render(refs)) } }
generated = true return emptyList() }Three things in there carry weight. getSymbolsWithAnnotation(ANNOTATION_FQN, false) returns a flat sequence of
annotated symbols — the false (inDepth) keeps it shallow, since @Validatable only ever lands on a top-level class,
never nested inside another annotated one. The List<KSAnnotated> that process returns is KSP’s deferral channel:
symbols that couldn’t be resolved this round and should be retried next one; a finished one-shot returns nothing.
And Dependencies(aggregating = false, *originatingFiles) is the incremental-build wiring — it ties the generated file
to the exact @Validatable sources it was built from, so editing one of them regenerates only what’s affected instead
of the whole module.
toValidatorRef is where the annotation is read. A @Validatable type names its validator one of two ways, and the
processor tries them in order — an explicit with = first, then the companion validator — walking the KSP symbol tree
rather than reflecting. These are all extension functions on KSClassDeclaration, nested in the processor:
private fun KSClassDeclaration.toValidatorRef(): ValidatorRef? { val receiverFqn = qualifiedName?.asString() ?: run { logger.warn("@Validatable type ${simpleName.asString()} has no qualified name", this) return null } val validatorExpression = customValidatorFqn() ?: companionValidatorExpression(receiverFqn) ?: return null return ValidatorRef(receiverFqn, validatorExpression)}The with = path has a wrinkle. KSP doesn’t hand you a KClass — it hands you a KSType, a symbol in its model of
the program, so you resolve the annotation, find the with argument, and read its declaration’s qualified name. The
catch is the default: @Validatable declares with = Validator::class as its “unset” sentinel, so a value equal to
sure.Validator means no custom validator was given and the code returns null to fall through to the companion:
private fun KSClassDeclaration.customValidatorFqn(): String? { val annotation = annotations.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == ANNOTATION_FQN } ?: return null
val withType = annotation.arguments .firstOrNull { it.name?.asString() == WITH_ARG } ?.value as? KSType ?: return null
val withFqn = withType.declaration.qualifiedName?.asString() return withFqn?.takeUnless { it == VALIDATOR_FQN } // default sentinel → not a custom validator}The companion path scans the class’s nested declarations for the companion object, then for a property named
validator. A missing one isn’t a reason to emit code that won’t compile — it’s a user mistake, so the processor
logger.errors against the offending class and fails the build with a message that says exactly how to fix it:
private fun KSClassDeclaration.companionValidatorExpression(receiverFqn: String): String? { val companion = declarations.filterIsInstance<KSClassDeclaration>().firstOrNull { it.isCompanionObject } val hasValidator = companion?.getAllProperties()?.any { it.simpleName.asString() == VALIDATOR_FIELD } == true return if (!hasValidator) { logger.error( "@Validatable class ${simpleName.asString()} must declare a `$VALIDATOR_FIELD` property in its " + "companion object, or specify @Validatable($WITH_ARG = SomeObject::class)", this, ) null } else { "$receiverFqn.$VALIDATOR_FIELD" }}The logger.warn vs logger.error split is deliberate: a class with no qualified name (anonymous or local) is just
skipped with a warning, but a @Validatable whose validator can’t be resolved is a hard error that stops the build.
There’s no clever code generator behind render. It’s a buildString that prints Kotlin source as text from the
(receiver, validator) pairs — no templating engine, no AST builder, just appendLine. For each @Validatable type it
emits a validate() extension, then one shared validatorsByClass map, a reified validatorFor<T>() lookup over it,
and a validated(::field) overload that uses that lookup to resolve a nested validator by type:
private fun render(refs: List<ValidatorRef>): String = buildString { appendLine("package $GENERATED_PACKAGE") appendLine() for (ref in refs) { appendLine( "fun ${ref.receiverFqn}.validate(): $VALIDATION_RESULT_FQN = " + "${ref.validatorExpression}.validate(this)", ) appendLine() } appendLine("@PublishedApi") appendLine("internal val validatorsByClass: Map<kotlin.reflect.KClass<*>, $VALIDATOR_FQN<*>> = mapOf(") for (ref in refs) { appendLine(" ${ref.receiverFqn}::class to ${ref.validatorExpression},") } appendLine(")") appendLine() appendLine("@Suppress(\"UNCHECKED_CAST\")") appendLine("inline fun <reified T : Any> validatorFor(): $VALIDATOR_FQN<T> =") appendLine(" validatorsByClass[T::class] as? $VALIDATOR_FQN<T>") appendLine(" ?: error(\"No validator registered for \${T::class.qualifiedName}\")") appendLine() // a validated(::field) overload that finds the sub-validator in the registry by type appendLine("context(_: $VALIDATION_SCOPE_FQN<*>)") appendLine("inline fun <reified F : Any> validated(property: kotlin.reflect.KProperty0<F>) =") appendLine(" validated(property, validatorFor<F>())")}}
private data class ValidatorRef(val receiverFqn: String, val validatorExpression: String)Everything is a fully-qualified name because generated source has no imports to lean on — $VALIDATION_RESULT_FQN,
kotlin.reflect.KClass, and the collected receiverFqns all print in full so the file compiles wherever it lands.
For the two @Validatable types above, that produces a single sure/GeneratedValidationExtensions.kt the compiler
picks up in the same build — one validate() per type, all of them in the shared registry:
package sure
fun com.example.Address.validate(): sure.ValidationResult = com.example.Address.validator.validate(this)
fun com.example.User.validate(): sure.ValidationResult = com.example.User.validator.validate(this)
@PublishedApiinternal val validatorsByClass: Map<kotlin.reflect.KClass<*>, sure.Validator<*>> = mapOf( com.example.Address::class to com.example.Address.validator, com.example.User::class to com.example.User.validator,)
@Suppress("UNCHECKED_CAST")inline fun <reified T : Any> validatorFor(): sure.Validator<T> = validatorsByClass[T::class] as? sure.Validator<T> ?: error("No validator registered for ${T::class.qualifiedName}")
context(_: sure.ValidationScope<*>)inline fun <reified F : Any> validated(property: kotlin.reflect.KProperty0<F>) = validated(property, validatorFor<F>())The validate() extension is what the call site at the very top of this post resolves to; validatorFor<T>() backs the
type-keyed registry that Validator was made reified for back in Step 9. The generated validated(::address) overload
closes a small loop with it — because every @Validatable type is in the registry, a nested field can pull its
sub-validator by type with no explicit reference, exactly the call the two snippets above use.
That convenience comes with a caveat worth stating plainly: validatorFor<F>() is a runtime map lookup, not a
compile-time guarantee. validated(::address) type-checks whether or not an Address validator was ever registered;
if the field’s type isn’t @Validatable (or its module wasn’t on the path when the registry was generated), the
lookup misses and error(...) throws at validation time. The explicit validated(::address, Address.validator) form
keeps that honest — passing the validator directly is checked by the compiler. So this is the usual derivation
trade-off: the zero-argument overload is convenient, the explicit one is statically safe.
This is the Kotlin equivalent of Scala’s automatic type-class derivation — except instead of inductive givens resolved
by the compiler, it’s a code generator emitting plain source. The same trade-off shows up there too: Scala’s implicit
resolution fails the compile, whereas this registry lookup can only fail at runtime.
That closes the loop: the public API from the very top of this post is now fully assembled.
Through the Decompiler
The library is done — the section below is a bonus for the curious. Most of the “magic” above exists only in source; decompiling the JVM target shows it flattening into ordinary bytecode patterns. If you don’t care what it lowers to, jump to Case Closed.
Context parameters are the clearest case.
A check like notBlank() has no receiver in Kotlin, but its context(_: ValidationScope<String>) lowers to a plain
leading parameter:
public static final void notBlank(ValidationScope $context) { check($context, NotBlankPredicate.INSTANCE, NotBlankMessage.INSTANCE);}field(::name) inlines into the caller.
The property reference becomes a fresh synthetic class with a get(); there is no reflective dispatch, just a
specialized getter call:
KProperty0 property$iv = (KProperty0) new PropertyReference0Impl($receiver) { public Object get() { return ((User) this.receiver).getName(); }};Object value$iv = property$iv.get();FieldScope fs = new FieldScope(value$iv, property$iv.getName(), scope$iv);// … inlined block body …reified T in Validator<User> { … } lowers to a literal User.class at the call site —
Reflection.getOrCreateKotlinClass(User.class) — captured into the Validator constructor.
The noinline rules lambda forced a real Function object, so it shows up as a synthetic class rather than copied-in
code; that’s the whole point of noinline.
Contracts leave no trace at all in bytecode.
returns(true) implies (this is T) and returnsNotNull() implies … are compile-time-only — they change what the Kotlin
compiler will let you write, then evaporate.
The decompiled isInstanceOf is a one-liner returning kClass.isInstance(this); the smart cast it enabled became an
ordinary, checked-at-source assignment with no runtime cast inserted.
Explicit backing fields collapse to exactly what you’d hand-write.
The val errors: List / field = mutableListOf() pair becomes one private field and a read-only getter — no second
property, and crucially no setter:
private final List errors = new ArrayList(); // the single backing fieldpublic final List getErrors() { return this.errors; } // read-only — no setErrors emittedInside the class, where errors means the MutableList, errors += message is just a method call on that same field:
((Collection) this.errors).add(message); // from `errors += message`So the encapsulation is real, not a wrapper: callers see List, the class mutates the one underlying instance, and
nothing is allocated to bridge the two.
fun interface doesn’t allocate a class per lambda.
Translator { key, args -> … } lowers to an invokedynamic call site backed by LambdaMetafactory — the same
machinery as a plain Kotlin/Java lambda, the runtime spins up the implementation on first use:
// the `Translator { … }` becomes:0: invokedynamic #40, 0 // InvokeDynamic #0:translate:()Lsure/Translator;Definitely-non-null types mostly vanish.
F & Any erases to its ordinary bound — there’s no special JVM type — so the guarantee is carried by @Metadata plus
the occasional Intrinsics.checkNotNullParameter guard the compiler drops in at a public boundary. At runtime it’s a
plain non-null reference like any other.
Case Closed
The library is, structurally, a type class: type-indexed dispatch (validatorFor<T>()), behavior parameterized by type,
and — via KSP — derivation. Where the Scala version of this story leans on Mirrors and inductive givens, Kotlin
gets there with context parameters and a KSP code generator for the derivation.
As it turns out, Kotlin can be scary too.
The full source — multiplatform targets, every built-in check, the KSP processor, tests — is at github.com/halotukozak/sure.