kotlin-sum-types

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
STARTER_CHARACTER = 🔀
STARTER_CHARACTER = 🔀

Parse, Don't Validate with Kotlin Sealed Classes

使用Kotlin密封类实现「解析而非验证」

Represent validation states explicitly using sealed classes. This makes invalid states unrepresentable in your domain logic and pushes validation to system boundaries.
使用密封类显式表示验证状态。这会让无效状态在领域逻辑中无法被表示,并将验证操作推至系统边界处执行。

Core Principle

核心原则

Parse, don't validate means transforming untyped input into strongly-typed domain objects at the boundary, carrying proof of validity through the type system.
Instead of:
kotlin
fun processEmail(email: String) {
    if (!isValid(email)) throw ValidationException()
    // Every function must revalidate or assume validity
}
Do this:
kotlin
fun processEmail(email: ValidEmail) {
    // Type proves it's valid, no need to check
}
解析而非验证指的是在系统边界处将无类型输入转换为强类型领域对象,通过类型系统携带有效性证明。
不要这样做:
kotlin
fun processEmail(email: String) {
    if (!isValid(email)) throw ValidationException()
    // 每个函数都必须重新验证或假设数据有效
}
应该这样做:
kotlin
fun processEmail(email: ValidEmail) {
    // 类型已证明数据有效,无需再检查
}

Basic Pattern: Valid/Invalid States

基础模式:有效/无效状态

Use sealed classes to represent parsed data that can be either valid or invalid:
kotlin
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))
                )
            }
    }
}
Key elements:
  • Sealed class as parent (Email)
  • ValidEmail with parsed structure (user, domain)
  • InvalidEmail preserving original value + errors
  • Static
    create()
    factory for parsing
  • @JsonCreator
    for Jackson integration
使用密封类表示已解析的、可能有效或无效的数据:
kotlin
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))
                )
            }
    }
}
核心元素:
  • 密封类作为父类(Email)
  • ValidEmail包含解析后的结构(user、domain)
  • InvalidEmail保留原始值及错误信息
  • 静态
    create()
    工厂方法用于解析
  • @JsonCreator
    用于Jackson集成

Validation Error Model

验证错误模型

Standard error representation:
kotlin
data 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>
}
All invalid states implement
InvalidDataClass
to expose errors uniformly.
标准错误表示方式:
kotlin
data class ValidationError(
    val path: String,      // 字段路径(例如:"address.city")
    val message: String,   // 人类可读的提示信息
    val value: String,     // 无效的输入值
)

interface InvalidDataClass {
    fun hasErrors(): Boolean = getErrors().isNotEmpty()
    fun getErrors(): List<ValidationError>
}
所有无效状态都实现
InvalidDataClass
接口,以统一暴露错误信息。

Composite Validation

复合验证

Build complex types by composing validated types:
kotlin
sealed 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)
        }
    }
}
InvalidAddress preserves all input: Even invalid data is kept so you can return meaningful error messages to users.
通过组合已验证的类型来构建复杂类型:
kotlin
sealed 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)
        }
    }
}
InvalidAddress保留所有输入: 即使是无效数据也会被保留,这样你可以向用户返回有意义的错误提示。

Nested Valid States

嵌套有效状态

Valid states can have their own hierarchy:
kotlin
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", ""))
                )
            }
        }
    }
}
Note the types:
  • Valid.Registration
    requires
    Email.ValidEmail
    and
    Address.ValidAddress
  • Invalid case can contain any
    Email
    and
    Address
    (valid or invalid)
  • Errors are propagated with paths ("email", "address.city")
有效状态可以拥有自己的层级结构:
kotlin
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,  // 仅允许ValidEmail!
    ) : RegistrationForm() {
        data class AnonymousRegistration(
            val _email: Email.ValidEmail,
        ) : Valid(_email)

        data class Registration(
            val _email: Email.ValidEmail,
            val name: String,
            val address: Address.ValidAddress,  // 仅允许ValidAddress!
        ) : Valid(_email)
    }

    companion object {
        @JvmStatic
        @JsonCreator
        fun create(
            email: Email,
            anonymous: Boolean,
            name: String?,
            address: Address?,
        ): RegistrationForm {
            // 收集嵌套验证类型中的错误
            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.Registration
    要求传入
    Email.ValidEmail
    Address.ValidAddress
  • 无效状态可以包含任意的
    Email
    Address
    (有效或无效均可)
  • 错误信息会携带路径(例如"email"、"address.city")向上传播

Controller Pattern

控制器模式

Handle valid/invalid cases at the boundary using
when
:
kotlin
sealed 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())
        }
}
Key aspects:
  • Parse JSON at the boundary
  • when
    expression handles valid/invalid exhaustively
  • Service receives only valid types
  • Errors automatically collected and returned
在系统边界处使用
when
表达式处理有效/无效状态:
kotlin
sealed 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())
        }
}
核心要点:
  • 在边界处解析JSON
  • when
    表达式会穷举处理所有有效/无效状态
  • 服务层仅接收有效类型的数据
  • 错误信息会被自动收集并返回

Jackson Integration

Jackson集成

Use
@JsonCreator
to hook into Jackson parsing:
kotlin
companion object {
    @JvmStatic
    @JsonCreator
    fun create(param1: Type1, param2: Type2): SealedClass {
        // Validation logic here
    }
}
Jackson calls
create()
during deserialization, giving you control over validation.
Dependencies:
kotlin
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
Usage:
kotlin
val mapper = jacksonObjectMapper()
val parsed: RegistrationForm = mapper.readValue(jsonString)
// parsed is either Valid or Invalid
使用
@JsonCreator
钩子接入Jackson的解析流程:
kotlin
companion object {
    @JvmStatic
    @JsonCreator
    fun create(param1: Type1, param2: Type2): SealedClass {
        // 此处编写验证逻辑
    }
}
Jackson会在反序列化过程中调用
create()
方法,让你可以完全控制验证流程。
依赖:
kotlin
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
用法:
kotlin
val mapper = jacksonObjectMapper()
val parsed: RegistrationForm = mapper.readValue(jsonString)
// parsed要么是Valid状态,要么是Invalid状态

Benefits

优势

Type Safety:
  • Invalid states are unrepresentable in domain logic
  • Compiler prevents passing invalid data to functions expecting valid types
  • when
    expressions ensure all cases are handled
Error Collection:
  • Multiple validation errors collected in one pass
  • Nested errors preserve field paths
  • Original invalid values preserved for debugging
Maintainability:
  • Validation logic centralized in
    create()
    factories
  • Domain logic operates only on valid types
  • Clear boundary between validated and unvalidated data
Refactoring Safety:
  • Adding new fields to valid types is a compiler error if not handled
  • Changing validation rules doesn't affect domain logic
  • IDE autocomplete shows all valid/invalid states
类型安全:
  • 无效状态在领域逻辑中无法被表示
  • 编译器会阻止将无效数据传递给期望有效类型的函数
  • when
    表达式确保所有状态都被处理
错误收集:
  • 一次处理即可收集多个验证错误
  • 嵌套错误会保留字段路径
  • 原始无效值会被保留,便于调试
可维护性:
  • 验证逻辑集中在
    create()
    工厂方法中
  • 领域逻辑仅操作有效类型
  • 已验证数据与未验证数据之间的边界清晰
重构安全性:
  • 若未处理新增字段,向有效类型添加新字段会触发编译器错误
  • 修改验证规则不会影响领域逻辑
  • IDE自动补全会显示所有有效/无效状态

When to Use This Pattern

何时使用该模式

Use sealed classes for validation when:
  • Parsing external input (JSON, CSV, user forms)
  • Multiple fields must be validated together
  • You need to collect multiple validation errors
  • Invalid data should be preserved for error reporting
  • Validation rules are complex or change frequently
Don't use when:
  • Simple non-null checks (use Kotlin's
    ?
    types)
  • Single-field validation with no composition
  • Data is already validated by external system (database constraints)
  • Performance is critical (adds allocation overhead)
在以下场景中使用密封类实现验证:
  • 解析外部输入(JSON、CSV、用户表单)
  • 多个字段需要一起验证
  • 需要收集多个验证错误
  • 无效数据需要被保留以生成错误报告
  • 验证规则复杂或频繁变更
请勿在以下场景使用:
  • 简单的非空检查(使用Kotlin的
    ?
    类型)
  • 无需组合的单字段验证
  • 数据已被外部系统验证(如数据库约束)
  • 性能要求极高(会带来内存分配开销)

Testing Strategy

测试策略

Test valid state creation:
kotlin
@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 invalid state creation:
kotlin
@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 composite validation:
kotlin
@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 controller handling:
kotlin
@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()
    }
}
测试有效状态的创建:
kotlin
@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")
    }
}
测试无效状态的创建:
kotlin
@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()
    }
}
测试复合验证:
kotlin
@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")
    }
}
测试控制器处理逻辑:
kotlin
@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()
    }
}

Real-World Example

真实示例

See complete working example:
  • Domain: RegistrationDomain.kt
  • Controller: ControllerLikeRegistrationController.kt
  • Tests: ParsingTest.kt
查看完整的可运行示例:
  • 领域层:RegistrationDomain.kt
  • 控制器:ControllerLikeRegistrationController.kt
  • 测试:ParsingTest.kt

Anti-Patterns

反模式

Don't validate in the domain:
kotlin
// BAD - validation scattered throughout domain
fun processRegistration(email: String, name: String) {
    require(email.contains("@")) { "Invalid email" }
    require(name.isNotBlank()) { "Name required" }
    // ... business logic
}
Do validation at boundaries, pass validated types to domain:
kotlin
// GOOD - validation at boundary, domain receives valid types
fun processRegistration(registration: RegistrationForm.Valid.Registration) {
    // registration.email is ValidEmail, no validation needed
}
Don't lose original invalid values:
kotlin
// BAD - can't tell user what they sent
data class InvalidEmail(val _errors: List<ValidationError>) : Email()
Keep the original value:
kotlin
// GOOD - can show user what they sent
data class InvalidEmail(
    val value: String,  // Preserve original input
    val _errors: List<ValidationError>
) : Email()
Don't use exceptions for expected validation:
kotlin
// BAD - exceptions for expected invalid input
fun create(email: String): ValidEmail {
    if (!isValid(email)) throw ValidationException()
    return ValidEmail(...)
}
Return sealed class representing valid/invalid:
kotlin
// GOOD - invalid input is expected, not exceptional
fun create(email: String): Email {  // Returns Valid or Invalid
    if (!isValid(email)) return InvalidEmail(...)
    return ValidEmail(...)
}
不要在领域逻辑中执行验证:
kotlin
// 糟糕的实现——验证逻辑分散在领域层各处
fun processRegistration(email: String, name: String) {
    require(email.contains("@")) { "Invalid email" }
    require(name.isNotBlank()) { "Name required" }
    // ... 业务逻辑
}
应该在边界处执行验证,将已验证的类型传入领域层:
kotlin
// 良好的实现——验证在边界处完成,领域层接收有效类型
fun processRegistration(registration: RegistrationForm.Valid.Registration) {
    // registration.email是ValidEmail,无需再验证
}
不要丢弃原始无效值:
kotlin
// 糟糕的实现——无法告知用户他们输入了什么
 data class InvalidEmail(val _errors: List<ValidationError>) : Email()
应该保留原始值:
kotlin
// 良好的实现——可以向用户展示他们输入的内容
 data class InvalidEmail(
    val value: String,  // 保留原始输入
    val _errors: List<ValidationError>
) : Email()
不要为预期的无效输入抛出异常:
kotlin
// 糟糕的实现——用异常处理预期的无效输入
fun create(email: String): ValidEmail {
    if (!isValid(email)) throw ValidationException()
    return ValidEmail(...)
}
应该返回表示有效/无效状态的密封类:
kotlin
// 良好的实现——无效输入是预期情况,而非异常
fun create(email: String): Email {  // 返回Valid或Invalid状态
    if (!isValid(email)) return InvalidEmail(...)
    return ValidEmail(...)
}

Related Patterns

相关模式

  • Railway-Oriented Programming: Result<T, E> types (similar but more functional)
  • Type-Driven Development: Using types to guide design
  • Domain-Driven Design: Validation at aggregate boundaries
  • Railway-Oriented Programming:Result<T, E>类型(类似但更偏向函数式风格)
  • Type-Driven Development:使用类型引导设计
  • Domain-Driven Design:在聚合根边界处执行验证

References

参考资料