Parse Don't Validate
Why ParseResult prevents invalid states from existing in the system.
Cross-document synthesis: Verified Design Synthesis.
The Problem with Validation
Traditional validation checks data and returns a boolean or throws an exception:
// ✗ Validation approach
fun validateConfig(json: String): Boolean {
return json.contains("flags") && json.contains("key")
}
val json = fetchConfig()
if (validateConfig(json)) {
// Still working with untyped String
// No guarantee it's actually valid
applyConfig(json) // This might still crash
}
Issues:
- Validated data remains in its original (untyped) form
- No compile-time guarantee that validated data is used correctly
- Validation checks can be bypassed or forgotten
- Invalid states can still be constructed after validation passes
Parse Don't Validate Principle
Parse means: transform untrusted input into a typed representation, failing early if impossible.
// ✓ Parse approach
sealed interface ParseResult<out T> {
data class Success<T>(val value: T) : ParseResult<T>
data class Failure(val error: ParseError) : ParseResult<Nothing>
}
fun parseConfig(json: String): ParseResult<Configuration> {
// Either return Success(validConfig) or Failure(error)
// No middle ground
}
Benefits:
- Type-states —
Successcontains a validConfiguration;Failurecontains aParseError - Exhaustive handling —
whenexpressions force you to handle both cases - No invalid states — If you have a
Configuration, it's already been validated - No silent failures — Parse failures are explicit, not exceptions
The Trust Boundary
JSON enters the system as an untrusted String. ConfigurationSnapshotCodec.decode(...) either produces a valid
Configuration (trusted) or a typed ParseError (rejected). Invalid state never enters the runtime.
How Konditional Applies This
The Boundary
val json: String = fetchRemoteConfig() // Untrusted
when (val result = ConfigurationSnapshotCodec.decode(json)) {
is ParseResult.Success -> {
val config: Configuration = result.value // Trusted
AppFeatures.load(config)
}
is ParseResult.Failure -> {
// Invalid JSON rejected — last-known-good remains active
logError(result.error.message)
}
}
Key insight: If you have a Configuration instance, it has already been parsed. You can't construct an invalid
Configuration from JSON — the codec is the only path.
Mechanism: Sealed Interface Guarantees
ParseResult is a sealed interface with exactly two subtypes:
sealed interface ParseResult<out T> {
data class Success<T>(val value: T) : ParseResult<T>
data class Failure(val error: ParseError) : ParseResult<Nothing>
}
Compiler enforcement:
when (val result = ConfigurationSnapshotCodec.decode(json)) {
is ParseResult.Success -> { /* handle success */ }
is ParseResult.Failure -> { /* handle failure */ }
// No other cases possible
// when-expression is exhaustive (compiler-verified)
}
If you forget to handle a case, the code won't compile.
No Exceptions Cross the Boundary
Traditional parsing throws exceptions:
// ✗ Exception-based parsing
try {
val config = JSON.parse(json) // Might throw
applyConfig(config)
} catch (e: Exception) {
// Easy to forget; exceptions are invisible in type signatures
logError(e)
}
Konditional uses ParseResult instead:
// ✓ Explicit boundary
when (val result = ConfigurationSnapshotCodec.decode(json)) {
is ParseResult.Success -> applyConfig(result.value)
is ParseResult.Failure -> logError(result.error.message)
}
Parse failures are explicit in the return type. The compiler forces you to handle them. No hidden control flow.
What the Codec Checks
Comparison: Validation vs Parsing
Validation (Traditional)
fun validateJson(json: String): Boolean {
return json.contains("flags")
}
val json = fetchConfig()
if (validateJson(json)) {
// json is still a String — no compile-time guarantee
processConfig(json)
}
After validation, json is still an untyped String. The caller can bypass validation. Invalid states remain constructable.
Parsing (Konditional)
fun parseJson(json: String): ParseResult<Configuration> {
// Transform to typed representation, or fail
}
when (val result = parseJson(json)) {
is ParseResult.Success -> processConfig(result.value) // typed, valid
is ParseResult.Failure -> { /* explicit */ }
}
Configuration is typed and guaranteed valid at the point you hold it. You can't get to processConfig(...) without
a successful parse.
Why This Matters for Production Safety
Traditional Approach: Silent Failures
val json = """{ "invalid": true }"""
if (validateJson(json)) {
applyConfig(json) // Might crash deep inside
}
// System continues with corrupt state
Konditional Approach: Fail-Safe Boundary
val json = """{ "invalid": true }"""
when (val result = ConfigurationSnapshotCodec.decode(json)) {
is ParseResult.Success -> { /* unreachable — JSON is invalid */ }
is ParseResult.Failure -> {
// Invalid JSON rejected
// Last-known-good config remains active
logError(result.error.message)
}
}
Operational guarantee: Invalid JSON cannot become active configuration.
The Guarantee
If you have a Configuration instance produced by decode(...), it is valid.
No need to:
- Re-validate before using it
- Check for null or undefined fields
- Guard against type mismatches at evaluation time
The codec did all that work upfront, at the boundary.
Test Evidence
| Test | Evidence |
|---|---|
BoundaryFailureResultTest | Parse failures remain typed and inspectable through result channel. |
ConfigurationSnapshotCodecTest | Snapshot decode enforces schema-aware trusted materialization. |
Next Steps
- Theory: Type Safety Boundaries — Compile-time vs. runtime guarantees
- Concept: Parse Boundary — Boundary in practice
- Reference: Snapshot Load Options — Strict vs. skip mode