Loading...
Loading...
Parse, don't validate - using sealed classes for type-safe validation and state representation. Model valid/invalid states explicitly, validate at boundaries, operate on valid types internally.
npx skill4agent add anderssv/the-example kotlin-sum-typesfun processEmail(email: String) {
if (!isValid(email)) throw ValidationException()
// Every function must revalidate or assume validity
}fun processEmail(email: ValidEmail) {
// Type proves it's valid, no need to check
}sealed class Email {
data class ValidEmail(
val user: String,
val domain: String,
) : Email() {
fun stringRepresentation(): String = "$user@$domain"
}
data class InvalidEmail(
val value: String,
val _errors: List<ValidationError>,
) : Email(), InvalidDataClass {
override fun getErrors(): List<ValidationError> = _errors
}
companion object {
@JvmStatic
@JsonCreator
fun create(createValue: String): Email =
if (createValue.contains("@")) {
createValue.split("@").let { ValidEmail(it.first(), it.last()) }
} else {
InvalidEmail(
createValue,
listOf(ValidationError("", "Not a valid Email", createValue))
)
}
}
}create()@JsonCreatordata class ValidationError(
val path: String, // Field path (e.g., "address.city")
val message: String, // Human-readable message
val value: String, // The invalid value
)
interface InvalidDataClass {
fun hasErrors(): Boolean = getErrors().isNotEmpty()
fun getErrors(): List<ValidationError>
}InvalidDataClasssealed class Address {
data class ValidAddress(
val streetName: String,
val city: String,
val postCode: String,
val country: String,
) : Address()
data class InvalidAddress(
val streetName: String?,
val city: String?,
val postCode: String?,
val country: String?,
val _errors: List<ValidationError>,
) : Address(), InvalidDataClass {
override fun getErrors(): List<ValidationError> = _errors
}
companion object {
@JvmStatic
@JsonCreator
fun create(
streetName: String?,
city: String?,
postCode: String?,
country: String?,
): Address {
if (streetName.isNullOrEmpty() || city.isNullOrEmpty() ||
postCode.isNullOrBlank() || country.isNullOrBlank()) {
return InvalidAddress(
streetName, city, postCode, country,
listOf(ValidationError("", "Missing required fields", "..."))
)
}
return ValidAddress(streetName, city, postCode, country)
}
}
}sealed class RegistrationForm {
data class Invalid(
val email: Email,
val anonymous: Boolean,
val name: String?,
val address: Address?,
val _errors: List<ValidationError>,
) : RegistrationForm(), InvalidDataClass {
override fun getErrors(): List<ValidationError> = _errors
}
sealed class Valid(
open val email: Email.ValidEmail, // Only ValidEmail allowed!
) : RegistrationForm() {
data class AnonymousRegistration(
val _email: Email.ValidEmail,
) : Valid(_email)
data class Registration(
val _email: Email.ValidEmail,
val name: String,
val address: Address.ValidAddress, // Only ValidAddress allowed!
) : Valid(_email)
}
companion object {
@JvmStatic
@JsonCreator
fun create(
email: Email,
anonymous: Boolean,
name: String?,
address: Address?,
): RegistrationForm {
// Collect errors from nested validated types
val errors = mapOf("email" to email, "address" to address)
.filter { it.value is InvalidDataClass }
.flatMap { (key, value) ->
(value as InvalidDataClass).getErrors()
.map { error ->
error.copy(
path = key + if (error.path.isNotEmpty()) ".${error.path}" else ""
)
}
}
return when {
errors.isNotEmpty() -> Invalid(email, anonymous, name, address, errors)
anonymous -> Valid.AnonymousRegistration(email as Email.ValidEmail)
name != null -> Valid.Registration(
email as Email.ValidEmail,
name,
address as Address.ValidAddress
)
else -> Invalid(
email, anonymous, name, address,
listOf(ValidationError("", "Invalid combination", ""))
)
}
}
}
}Valid.RegistrationEmail.ValidEmailAddress.ValidAddressEmailAddresswhensealed class ControllerResponse {
data class OkResponse(val result: String) : ControllerResponse()
data class ErrorResponse(val errors: List<ValidationError>) : ControllerResponse()
}
class RegistrationController(
private val registrationService: RegistrationService,
) {
private val mapper = jacksonObjectMapper()
fun registerUser(jsonString: String): ControllerResponse =
when (val parsed: RegistrationForm = mapper.readValue(jsonString)) {
is RegistrationForm.Valid -> {
registrationService.createNewRegistration(parsed)
when (parsed) {
is RegistrationForm.Valid.Registration ->
ControllerResponse.OkResponse("Congrats ${parsed.name}!")
is RegistrationForm.Valid.AnonymousRegistration ->
ControllerResponse.OkResponse("Congrats!")
}
}
is RegistrationForm.Invalid ->
ControllerResponse.ErrorResponse(parsed.getErrors())
}
}when@JsonCreatorcompanion object {
@JvmStatic
@JsonCreator
fun create(param1: Type1, param2: Type2): SealedClass {
// Validation logic here
}
}create()implementation("com.fasterxml.jackson.module:jackson-module-kotlin")val mapper = jacksonObjectMapper()
val parsed: RegistrationForm = mapper.readValue(jsonString)
// parsed is either Valid or Invalidwhencreate()?@Test
fun shouldParseValidEmail() {
val parsed = Email.create("user@example.com")
assertThat(parsed).isInstanceOf(Email.ValidEmail::class.java)
(parsed as Email.ValidEmail).let {
assertThat(it.user).isEqualTo("user")
assertThat(it.domain).isEqualTo("example.com")
}
}@Test
fun shouldParseInvalidEmail() {
val parsed = Email.create("not-an-email")
assertThat(parsed).isInstanceOf(Email.InvalidEmail::class.java)
(parsed as Email.InvalidEmail).let {
assertThat(it.value).isEqualTo("not-an-email")
assertThat(it.getErrors()).isNotEmpty()
}
}@Test
fun shouldCollectNestedErrors() {
val form = RegistrationForm.create(
email = Email.create("invalid"),
anonymous = false,
name = null,
address = Address.create(null, null, null, null)
)
assertThat(form).isInstanceOf(RegistrationForm.Invalid::class.java)
(form as RegistrationForm.Invalid).let {
val errorPaths = it.getErrors().map { e -> e.path }
assertThat(errorPaths).contains("email", "address")
}
}@Test
fun shouldReturnErrorResponseForInvalidInput() {
val response = controller.registerUser("""{"email": "bad"}""")
assertThat(response).isInstanceOf(ControllerResponse.ErrorResponse::class.java)
(response as ControllerResponse.ErrorResponse).let {
assertThat(it.errors).isNotEmpty()
}
}// BAD - validation scattered throughout domain
fun processRegistration(email: String, name: String) {
require(email.contains("@")) { "Invalid email" }
require(name.isNotBlank()) { "Name required" }
// ... business logic
}// GOOD - validation at boundary, domain receives valid types
fun processRegistration(registration: RegistrationForm.Valid.Registration) {
// registration.email is ValidEmail, no validation needed
}// BAD - can't tell user what they sent
data class InvalidEmail(val _errors: List<ValidationError>) : Email()// GOOD - can show user what they sent
data class InvalidEmail(
val value: String, // Preserve original input
val _errors: List<ValidationError>
) : Email()// BAD - exceptions for expected invalid input
fun create(email: String): ValidEmail {
if (!isValid(email)) throw ValidationException()
return ValidEmail(...)
}// GOOD - invalid input is expected, not exceptional
fun create(email: String): Email { // Returns Valid or Invalid
if (!isValid(email)) return InvalidEmail(...)
return ValidEmail(...)
}