Sealed class và when trong Kotlin

1. Tổng quan

Khi một giá trị chỉ có thể rơi vào vài trường hợp cố định, ví dụ kết quả thanh toán là thành công, thất bại hoặc đang chờ, bạn muốn trình biên dịch biết rõ danh sách đó. Kotlin cung cấp sealed class và sealed interface để khóa cây kế thừa. Biểu thức when có thể kiểm tra xem bạn đã xử lý đủ mọi nhánh hay chưa.

Đọc xong bài này, bạn sẽ biết cách khai báo sealed hierarchy và viết when exhaustive. Bạn cũng sẽ biết dùng guard condition, viết test cho logic này và nhận ra khi nào không nên dùng sealed class.

2. Vấn đề với cây kế thừa mở

Thử mô hình hóa kết quả thanh toán bằng một abstract class thông thường:

abstract class PaymentResult

class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()

fun describe(result: PaymentResult): String = when (result) {
    is Success -> "OK: ${result.transactionId}"
    is Failed -> "Lỗi: ${result.reason}"
    else -> "Không rõ"
}

Nhánh else là bắt buộc. Lý do là bất kỳ ai, ở bất kỳ module nào, cũng có thể tạo thêm lớp con của PaymentResult. Trình biên dịch không thể biết danh sách lớp con đã đủ hay chưa.

Hệ quả là khi ai đó thêm class Refunded : PaymentResult(), code vẫn biên dịch bình thường. Giá trị Refunded âm thầm rơi vào nhánh "Không rõ". Lỗi kiểu này thường chỉ lộ ra trên production.

enum class giải quyết được một phần vì danh sách hằng là cố định. Tuy nhiên mỗi hằng enum là một instance duy nhất. Bạn không thể gắn transactionId khác nhau cho từng lần thanh toán thành công.

3. Khai báo sealed class

Sealed class kết hợp ưu điểm của cả hai cách trên. Danh sách lớp con là cố định như enum, và mỗi lớp con vẫn mang dữ liệu riêng như class thường.

3.1. Dependency

Ví dụ trong bài dùng Kotlin 2.2 trên JVM 21. Phần test dùng JUnit 5 và AssertJ:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.2.20</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.4</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.27.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Bạn cũng cần cấu hình kotlin-maven-plugin cùng phiên bản để biên dịch mã nguồn Kotlin.

3.2. Một sealed hierarchy đơn giản

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
}

Từng phần trong đoạn code trên có ý nghĩa như sau:

  • sealed class PaymentResult là lớp abstract ngầm định. Bạn không thể gọi PaymentResult() trực tiếp. Constructor của nó mặc định là protected.
  • Success và Failed là data class. Bạn có sẵn equals, hashCode, toString và copy, rất tiện khi viết test.
  • Pending không mang dữ liệu, nên dùng data object. Nó là singleton và có toString() in ra Pending thay vì kiểu PaymentResult$Pending@1b6d3586.

Quy tắc quan trọng nhất: mọi lớp con trực tiếp phải nằm cùng package và cùng module với sealed class. Bạn có thể tách chúng ra nhiều file trong cùng package. Tuy nhiên một module khác dùng thư viện của bạn sẽ không thể thêm lớp con mới. Chính nhờ quy tắc này mà trình biên dịch biết chắc danh sách lớp con.

4. Biểu thức when exhaustive

4.1. Bỏ nhánh else

Khi đối tượng kiểm tra của when có kiểu sealed, bạn không cần else nữa:

fun describe(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "Đã thanh toán ${result.amount} VND (mã ${result.transactionId})"
    is PaymentResult.Failed -> "Thất bại: ${result.reason}"
    PaymentResult.Pending -> "Đang chờ xử lý"
}

fun main() {
    println(describe(PaymentResult.Success("TX-1001", 250_000)))
    println(describe(PaymentResult.Failed("Thẻ hết hạn", retryable = false)))
    println(describe(PaymentResult.Pending))
}

Kết quả khi chạy:

Đã thanh toán 250000 VND (mã TX-1001)
Thất bại: Thẻ hết hạn
Đang chờ xử lý

Có ba điểm đáng chú ý trong đoạn code trên:

  • Smart cast: trong nhánh is PaymentResult.Success, biến result được tự động ép kiểu sang Success. Vì vậy bạn truy cập result.amount mà không cần cast thủ công.
  • Với object, bạn so sánh trực tiếp bằng PaymentResult.Pending mà không cần is. Viết is PaymentResult.Pending cũng đúng, nhưng không cần thiết.
  • when ở đây là một biểu thức, tức là nó trả về giá trị và được gán làm thân hàm. Biểu thức when bắt buộc phải exhaustive.

4.2. Trình biên dịch bắt lỗi khi thêm trường hợp mới

Giả sử bạn thêm một trạng thái hoàn tiền:

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
    data class Refunded(val transactionId: String) : PaymentResult()
}

Hàm describe lập tức không biên dịch được:

e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.

Đây chính là giá trị cốt lõi của sealed class. Mọi chỗ trong code dùng when trên PaymentResult đều bị trình biên dịch chỉ ra. Bạn không thể quên chỗ nào.

4.3. when dạng câu lệnh

Từ Kotlin 1.7, ngay cả khi when được dùng như câu lệnh (không lấy giá trị trả về), trình biên dịch vẫn báo lỗi nếu bạn thiếu nhánh. Điều kiện là subject có kiểu sealed, enum hoặc Boolean:

fun log(result: PaymentResult) {
    when (result) {
        is PaymentResult.Success -> println("INFO thanh toán ${result.transactionId}")
        is PaymentResult.Failed -> println("WARN ${result.reason}")
        PaymentResult.Pending -> println("DEBUG đang chờ")
        is PaymentResult.Refunded -> println("INFO hoàn tiền ${result.transactionId}")
    }
}

Nếu bỏ nhánh Refunded, bạn nhận lỗi biên dịch chứ không chỉ là warning như ở các phiên bản Kotlin cũ.

5. Sealed interface

Một class chỉ kế thừa được một class cha, nhưng có thể implement nhiều interface. Vì vậy sealed interface linh hoạt hơn khi một kiểu thuộc về nhiều nhóm cùng lúc:

sealed interface OrderEvent
sealed interface Notifiable {
    val customerEmail: String
}

data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent

fun handle(event: OrderEvent): String = when (event) {
    is OrderPlaced -> "Tạo hóa đơn cho ${event.orderId}"
    is OrderShipped -> "Mã vận đơn ${event.trackingCode}"
    is StockReserved -> "Giữ hàng ${event.sku}"
}

fun emailOf(target: Notifiable): String = when (target) {
    is OrderPlaced -> "Gửi xác nhận đơn tới ${target.customerEmail}"
    is OrderShipped -> "Gửi thông báo giao hàng tới ${target.customerEmail}"
}

OrderPlaced vừa là OrderEvent, vừa là Notifiable. Hàm emailOf chỉ cần hai nhánh, vì trình biên dịch biết Notifiable chỉ có đúng hai lớp implement.

Sealed interface cũng hợp với enum. Một enum class có thể implement sealed interface, và khi đó từng hằng enum được tính như một nhánh trong when.

5.1. Lồng nhiều cấp

Một lớp con cũng có thể là sealed. Nhờ đó bạn phân nhóm chi tiết hơn:

sealed interface PaymentError {
    sealed interface Retryable : PaymentError
    data object Timeout : Retryable
    data object GatewayBusy : Retryable
    data class CardDeclined(val code: String) : PaymentError
}

fun shouldRetry(error: PaymentError): Boolean = when (error) {
    is PaymentError.Retryable -> true
    is PaymentError.CardDeclined -> false
}

Nhánh is PaymentError.Retryable đã bao phủ cả Timeout lẫn GatewayBusy, nên when vẫn exhaustive dù chỉ có hai nhánh.

6. Guard condition trong when

Đôi khi cùng một kiểu nhưng bạn muốn xử lý khác nhau tùy dữ liệu bên trong. Kotlin 2.2 hỗ trợ chính thức guard condition (từ khóa if sau điều kiện nhánh):

fun nextAction(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "Gửi hóa đơn"
    is PaymentResult.Failed if result.retryable -> "Thử lại sau 30 giây"
    is PaymentResult.Failed -> "Báo lỗi cho khách"
    PaymentResult.Pending -> "Chờ webhook từ cổng thanh toán"
    is PaymentResult.Refunded -> "Cập nhật sổ kế toán"
}

Nhánh có guard chỉ khớp khi cả kiểu lẫn điều kiện if đều đúng. Trình biên dịch không coi nhánh có guard là đã bao phủ Failed. Vì vậy nếu thiếu nhánh is PaymentResult.Failed không có guard, bạn vẫn nhận lỗi exhaustive.

Lưu ý: Ở Kotlin 2.1, guard condition còn là tính năng preview và cần cờ -Xwhen-guards. Hãy kiểm tra phiên bản Kotlin của dự án trước khi dùng.

7. Kết hợp trong một service

Trong code thực tế, sealed class thường là kiểu trả về của service thay cho việc ném exception:

data class Order(val id: String, val amount: Long)

class PaymentService {
    fun charge(order: Order): PaymentResult = when {
        order.amount <= 0 -> PaymentResult.Failed("Số tiền không hợp lệ", retryable = false)
        order.amount > 50_000_000 -> PaymentResult.Pending
        else -> PaymentResult.Success("TX-${order.id}", order.amount)
    }
}

fun main() {
    val service = PaymentService()
    when (val result = service.charge(Order("A42", 120_000))) {
        is PaymentResult.Success -> println("Mã giao dịch: ${result.transactionId}")
        else -> println(describe(result))
    }
}
Mã giao dịch: TX-A42

Ở hàm charge, when không có subject nên cần else. Ở đây else hoàn toàn hợp lý vì các điều kiện là biểu thức boolean tùy ý.

Trong main, cú pháp when (val result = ...) vừa gọi service vừa giới hạn phạm vi biến result trong khối when. Nhánh else ở đây là cố ý: bạn chỉ quan tâm trường hợp thành công. Mục 9 sẽ nói rõ cái giá của lựa chọn này.

8. Viết test

Nhờ data class, bạn so sánh kết quả bằng isEqualTo rất gọn:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PaymentServiceTest {

    private val service = PaymentService()

    @Test
    fun `đơn hợp lệ trả về Success`() {
        val result = service.charge(Order("A1", 99_000))

        assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
    }

    @Test
    fun `số tiền âm trả về Failed không thử lại`() {
        val result = service.charge(Order("A2", -5))

        assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
        assertThat((result as PaymentResult.Failed).retryable).isFalse()
    }

    @Test
    fun `đơn lớn chuyển sang Pending`() {
        assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
    }

    @Test
    fun `lỗi có thể thử lại dẫn tới hành động retry`() {
        val action = nextAction(PaymentResult.Failed("Gateway timeout", retryable = true))

        assertThat(action).isEqualTo("Thử lại sau 30 giây")
    }
}

Bộ test này kiểm tra ba điểm:

  • Test đầu tiên dựa vào equals do data class sinh ra để so sánh toàn bộ dữ liệu.
  • isSameAs dùng cho data object, vì Pending là singleton nên phép so sánh tham chiếu là đủ.
  • Test cuối kiểm tra nhánh có guard condition.

9. Lỗi hay gặp và khi nào không nên dùng

Thêm else vào when trên kiểu sealed. Đây là lỗi phổ biến nhất. Một nhánh else “cho chắc” sẽ tắt hoàn toàn cơ chế kiểm tra exhaustive. Khi thêm lớp con mới, trình biên dịch sẽ im lặng. Chỉ dùng else khi bạn thật sự muốn gom mọi trường hợp còn lại, như ví dụ ở mục 7.

Dùng when không có subject. when { result is Success -> ... } không được kiểm tra exhaustive theo sealed hierarchy. Hãy viết when (result) để trình biên dịch phân tích được.

Quên data cho object. Một object Pending thường vẫn chạy được. Tuy nhiên log sẽ in ra tên lớp kèm hash code, gây khó đọc khi debug.

Đặt lớp con ngoài module. Nếu bạn viết thư viện và muốn người dùng tự mở rộng (ví dụ plugin), sealed class là lựa chọn sai. Hãy dùng interface hoặc abstract class thông thường.

Lưu ý: Không nên dùng sealed class khi:

  • Các trường hợp chỉ là hằng số không mang dữ liệu, như LOW, MEDIUM, HIGH. Lúc này enum class gọn hơn và có sẵn entries, valueOf.
  • Danh sách lớp con thay đổi rất thường xuyên và được nhiều team ở nhiều module cùng đóng góp.
  • Hành vi nên nằm trong chính đối tượng (đa hình qua method override) thay vì rải when khắp nơi. Nếu cùng một when lặp lại ở mười chỗ, hãy cân nhắc chuyển logic vào method của từng lớp con.

10. Kết luận

Sealed class và sealed interface giúp bạn khóa cây kế thừa vào một module. Nhờ đó trình biên dịch biết đầy đủ mọi trường hợp. Kết hợp với when có subject, bạn được smart cast, không cần else, và nhận lỗi biên dịch ngay khi quên xử lý một trạng thái mới.

Hãy dùng data class cho trường hợp mang dữ liệu và data object cho trường hợp không mang dữ liệu. Chọn sealed interface khi cần một kiểu thuộc nhiều nhóm. Guard condition của Kotlin 2.2 giúp tách nhánh theo dữ liệu bên trong. Điều quan trọng nhất là tránh nhánh else không cần thiết, vì nó lấy mất chính lợi ích bạn đang tìm kiếm.

kotlinsealed classwhenpattern matchingtype safety