Skip to content

ValidationContext and ValidationPath

tessoir edited this page Mar 24, 2026 · 3 revisions

ValidationContext & 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.


Practical usage

ValidationPath

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}") }

EmptyValidationContext

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

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

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

End-to-end example

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

How it works

ValidationContext

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")

ValidationContext.Element

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

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>()?.requestId

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

Custom context elements

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

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

Next steps

  • Verification — the primary surface for chaining rules
  • Rule — learn how to write your own rules

Clone this wiki locally