-
Notifications
You must be signed in to change notification settings - Fork 1
ValidationContext and ValidationPath
For most use cases, the path system works automatically — verify(property) appends the property name to the path with
no extra work. You can use everything on the Built-in Rules page and get meaningful paths in your
violations without reading any further.
This page is for users who want to understand how path building works, take manual control of it, or extend the context system with custom data.
A ValidationPath is an ordered sequence of segments that identifies where in a validated structure a violation
occurred. Each segment is either a NamePathElement — representing a named field or property — or an
IndexPathElement — representing a position within a collection.
When you pass a property reference to verify, KVerify automatically appends a NamePathElement to the path:
validateCollecting {
verify(user::address).notNull() // path: ["address"]
verify(user::name).notBlank() // path: ["name"]
}For nested objects, path segments stack up naturally:
data class Address(val street: String, val city: String)
data class User(val name: String, val address: Address)
val result = validateCollecting {
verify(user::name).notBlank()
val address = user.address
pathName("address") {
verify(address::street).notBlank() // path: ["address", "street"]
verify(address::city).notBlank() // path: ["address", "city"]
}
}To read the path from a violation:
result.violations
.filterIsInstance<PathAwareViolation>()
.forEach { println("${it.validationPath}: ${it.reason}") }Every validation starts with EmptyValidationContext — the default context carrying no elements. It's the baseline
from which all path segments are added. Rarely interact with it directly, but it is what validationContext
returns at the root of any scope before any path segments have been appended.
pathName appends a named segment to the path. It comes in two forms.
Without a block — returns a new scope for chaining. Useful when you want to attach a custom name to a single value, for example a user-facing or localized field name:
validateCollecting {
pathName("correo_electronico").verify(request.email).notBlank() // path: ["correo_electronico"]
}With a block — runs the block in the new scope and returns Unit. Useful for grouping related checks under a common
path segment:
validateCollecting {
pathName("shippingAddress") {
verify(request::street).notBlank() // path: ["shippingAddress", "street"]
verify(request::city).notBlank() // path: ["shippingAddress", "city"]
}
}pathIndex appends an index segment to the path. It mirrors pathName but for positional contexts. In most cases
each handles index tracking automatically — pathIndex is there when you need manual control.
Without a block — returns a new scope for chaining:
validateCollecting {
request.items.forEachIndexed { index, item ->
pathIndex(index).verify(item::name).notBlank() // path: [0], [1], [2], ...
}
}With a block — runs the block in the new scope and returns Unit:
validateCollecting {
request.items.forEachIndexed { index, item ->
pathIndex(index) {
verify(item::name).notBlank() // path: [0], [1], [2], ...
verify(item::price).greaterThan(0.0)
}
}
}data class OrderItem(val name: String, val price: Double)
data class Order(val customerName: String, val items: List<OrderItem>)
val order = Order(
customerName = "",
items = listOf(
OrderItem(name = "Widget", price = 9.99),
OrderItem(name = "", price = -1.0),
)
)
val result = validateCollecting {
verify(order::customerName).notBlank()
verify(order::items).each { item ->
verify(item::name).notBlank()
verify(item::price).greaterThan(0.0)
}
}
result.violations
.filterIsInstance<PathAwareViolation>()
.forEach { println("${it.validationPath}: ${it.reason}") }Output:
ValidationPath("customerName"): Value must not be blank
ValidationPath("items", 1, "name"): Value must not be blank
ValidationPath("items", 1, "price"): Value must be greater than 0.0. Actual: -1.0
ValidationContext is an immutable, iterable container of Elements that travels through the validation process. Every
ValidationScope carries one, and it grows as path segments are appended via verify(property), pathName, or
pathIndex.
ValidationContext is composable — the plus operator combines two contexts into a new one without mutating either:
val combined = context + NamePathElement("address")Element is the building block of a context. Every segment of information attached to a validation scope — including
path elements — is an Element. NamePathElement and IndexPathElement are both Element implementations.
An Element is itself a valid ValidationContext containing exactly itself, which is what makes composition work
uniformly.
lastOfTypeOrNull is a built-in utility function on ValidationContext that retrieves the most recently added element
of a given type, or null if none exists:
val requestId = scope.validationContext.lastOfTypeOrNull<RequestIdElement>()?.requestIdThe last-wins behavior means that if a nested scope adds a more specific element of the same type, it takes precedence over any parent scope's element.
You can attach arbitrary data to a validation scope by implementing ValidationContext.Element. This data then travels
with the scope and can be read back inside any rule.
A practical example: attaching a request ID to the scope so it can be included in validation responses sent back to the frontend:
class RequestIdElement(val requestId: String) : ValidationContext.ElementThe real value of custom context elements shows up in reusable rules. A rule defined as an extension function has no access to the call site — the only way to thread data into it implicitly is through the context.
Here, a reusable notBlank rule reads the request ID from context to include it in every violation it produces, without
the caller needing to pass it explicitly:
data class NotBlankTrackedViolation(
val requestId: String,
override val validationPath: ValidationPath,
) : PathAwareViolation {
override val reason = "[$requestId] Field must not be blank"
}
fun Verification<String>.notBlankTracked(): Verification<String> =
apply {
val requestId = scope.validationContext.lastOfTypeOrNull<RequestIdElement>()?.requestId ?: "unknown"
scope.failIf({ value.isBlank() }) {
NotBlankTrackedViolation(
requestId = requestId,
validationPath = scope.validationContext.validationPath(),
)
}
}The caller attaches the request ID once and every rule that needs it can pull it from the context:
val result = validateCollecting(validationContext = RequestIdElement("req-abc-123")) {
verify(request::username).notBlankTracked()
verify(request::email).notBlankTracked()
}No request ID parameter anywhere in the call chain — it travels through the context invisibly.
- Verification — the primary surface for chaining rules
- Rule — learn how to write your own rules