《Java 高级编程》7.2 sealed 层次与穷尽 switch:把错误码变成类型

ErrorCode 枚举加 default 分支的问题不是写法丑,而是新增错误码时编译器不会提醒任何消费方。本节用 LoanState 和 LoanResult 的 sealed 层次演示穷尽 switch,真实编译一次「新增子类型」的失败,并给出 libraryd 中罚金、下一步动作与 HTTP 映射的落地代码。

本节目标:把「可能的结果」建模成封闭的类型集合,让 switch 在新增子类型时编译失败,而不是靠 default 把漏分支藏到运行期。
适用版本:JDK 25(Temurin 25.0.5)。sealed 类与接口在 JDK 17 正式,模式匹配 switch 在 JDK 21 正式。

承接的问题

libraryd 的错误处理长期依赖一个枚举:

public enum ErrorCode {
    BOOK_NOT_FOUND, MEMBER_NOT_FOUND, BOOK_ALREADY_BORROWED,
    DUPLICATE_ISBN, LOAN_NOT_FOUND, LOAN_LIMIT_EXCEEDED,
    RENEWAL_NOT_ALLOWED, INVALID_COMMAND, VALIDATION_FAILED,
    DB_UNAVAILABLE, INTERNAL
}

调用方通常这样映射 HTTP 状态码:

// 反例:default 把新增错误码吞掉
static int statusOf(ErrorCode code) {
    return switch (code) {
        case BOOK_NOT_FOUND, MEMBER_NOT_FOUND, LOAN_NOT_FOUND -> 404;
        case VALIDATION_FAILED, INVALID_COMMAND -> 400;
        case DB_UNAVAILABLE, INTERNAL -> 503;
        default -> 409;
    };
}

这段代码在编译期是「安全」的,在演化时却不安全。假设下个版本新增 RENEWAL_WINDOW_CLOSED,语义上应该返回 409,但代码作者可能认为是 400。编译器不会提醒这里有一个 switch 需要复核,因为 default 已经覆盖了所有未知值。错误码越多,default 越像一张地毯,问题全被扫到下面。

更麻烦的是,ErrorCode 只描述「错误类别」,不携带「哪本书、哪个会员、哪次借阅」。调用方拿到 code 和一段 detail 字符串,只能靠字符串匹配做二次判断。类型系统在这里没有提供任何帮助。

sealed 层次改变的是这件事:它把「可能的结果」变成一个封闭的集合,消费方的 switch 可以不带 default,编译器负责证明每个子类型都被处理。新增子类型时,所有未处理的 switch 一起编译失败。这不是更严格的代码风格,而是把「漏处理」从运行期提前到构建期。

核心机制一:sealed 接口定义封闭的形态集合

LoanState 是 libraryd 里最小的 sealed 层次:

public sealed interface LoanState
        permits LoanState.Active, LoanState.Overdue, LoanState.Returned {

    record Active(LocalDate dueOn) implements LoanState {
    }

    record Overdue(LocalDate dueOn, long days) implements LoanState {
    }

    record Returned(LocalDate returnedOn, long fineCents) implements LoanState {
    }
}

四个关键字各有职责:

  • sealed 表示「直接子类型是有限且已知的」。
  • permits 列出允许的直接子类型。如果所有子类型都写在同一个源文件里,permits 可以省略,编译器从同一文件推断;跨文件时必须显式写。
  • record 作为子类型时隐式是 final,不能再被继承。这正好符合「状态只有三种形态」的语义。
  • 子类型也可以声明为 sealed(继续封闭)或 non-sealed(重新开放继承)。non-sealed 会破坏穷尽性,后文会讲它的代价。

sealed 的封闭性有物理约束,不只是编译器检查。子类型必须与父类型在同一个模块;如果不在同一个模块,必须在同一个包。这条规则保证「封闭集合」不会被下游模块偷偷扩展。libraryd 的 LoanState 和 LoanResult 都放在 libraryd-domain 的 com.plume.library.domain 包里,满足同包约束。

LoanResult 用了同样的结构:

public sealed interface LoanResult
        permits LoanResult.Opened, LoanResult.Rejected {

    record Opened(Loan loan) implements LoanResult {
    }

    record Rejected(ErrorCode code, String detail) implements LoanResult {
    }
}

这里有一个刻意的折中:Rejected 仍然携带 ErrorCode 枚举和一段字符串,而不是把每种失败都做成独立类型。原因是 LoanResult 是跨章节契约,第 7 章之前的所有代码都按这个签名写。sealed 保证的是「结果只有成功和失败两种形态」,ErrorCode 描述失败内部的细分。两者解决的是不同粒度的问题。

核心机制二:穷尽 switch 不需要 default

有了 sealed 层次,消费方可以写:

static String bucket(LoanState state) {
    return switch (state) {
        case LoanState.Active(var dueOn) -> "ACTIVE:" + dueOn;
        case LoanState.Overdue(var dueOn, var days) -> "OVERDUE:" + days;
        case LoanState.Returned(var returnedOn, var cents) -> "RETURNED:" + cents;
    };
}

这个 switch 没有 default。编译器知道 LoanState 的直接子类型只有三个,且每个分支都覆盖了一个,所以表达式在所有输入上都有值。如果漏掉 Returned,编译错误是:

error: the switch expression does not cover all possible input values

这条规则只对「sealed 层次 + 模式 switch」成立。对普通接口或枚举,编译器仍然要求 default 或穷尽所有枚举常量。枚举的穷尽性是另一套机制:枚举常量本身是封闭的,但枚举不能携带不同形状的负载。LoanState.Active 有 dueOn,Overdue 有 dueOn 和 days,Returned 有 returnedOn 和 fineCents,这些字段用枚举表达会退化成「一个枚举加一堆可空字段」。

穷尽 switch 还可以写成语句形式:

static int weight(LoanState state) {
    return switch (state) {
        case LoanState.Active ignored -> 1;
        case LoanState.Overdue ignored -> 2;
        case LoanState.Returned ignored -> 3;
    };
}

这里用 ignored 绑定整个 record,而不是解构组件,因为只关心类型。绑定名可以是 _,但 _ 在 switch 的 case 模式里也可以使用。两者语义相同,_ 更明确地表达「不读取」。

本机运行 SealedSwitchDemo 的输出:

ACTIVE:2026-11-13 weight=1
OVERDUE:5 weight=2
RETURNED:50 weight=3

三个分支都命中了。注意 Overdue 的 days 是 5,Returned 的 fineCents 是 50,说明 record pattern 解构出的字段和构造时传入的值一致,没有经过额外的转换。

核心机制三:新增子类型时,编译器替你找齐所有消费方

这是 sealed 最重要的收益,也是最容易被误解的地方。很多人以为 sealed 只是「不让别人继承」,真正的价值在演化:新增一个子类型,所有未覆盖它的 switch 立刻编译失败。

为了验证,我在独立目录里给 LoanState 增加第四个形态:

public sealed interface LoanState
        permits LoanState.Active, LoanState.Overdue,
                LoanState.Returned, LoanState.Lost {

    record Active(LocalDate dueOn) implements LoanState {
    }

    record Overdue(LocalDate dueOn, long days) implements LoanState {
    }

    record Returned(LocalDate returnedOn, long fineCents) implements LoanState {
    }

    record Lost(LocalDate reportedOn) implements LoanState {
    }
}

Lost 表示图书遗失后由管理员手工结清的状态。只改这一处,重新编译 SealedSwitchDemo,本机 javac --release 25 输出:

src/com/plume/library/demo/SealedSwitchDemo.java:10: error: the switch expression does not cover all possible input values
        return switch (state) {
               ^
src/com/plume/library/demo/SealedSwitchDemo.java:18: error: the switch expression does not cover all possible input values
        return switch (state) {
               ^
2 errors

两个 switch 都被点名,行号精确到表达式。这就是「编译器替你找齐消费方」的具体形态:不需要 grep,不需要等集成测试,构建阶段就失败。

代价也要说清楚。新增子类型会让所有下游模块的编译同时失败,如果 LoanState 是一个被很多团队依赖的库类型,这会形成发布协调问题。三种应对方式:

  • 在同一个仓库、同一个发布单元里演化。libraryd 是单仓库五模块,构建一起跑,失败可以一起修,这是最舒服的场景。
  • 如果类型要跨团队发布,先用 non-sealed 留出扩展点,或者在 sealed 层次外面包一层适配器。non-sealed 的代价是穷尽性消失,消费方又需要 default。
  • 如果新增形态只是「多一个错误码」,不要新增 sealed 子类型,扩展 ErrorCode 枚举更合适。sealed 适合「形状不同」,枚举适合「标签不同」。

核心机制四:把错误码继续类型化,边界在哪里

LoanResult.Rejected(ErrorCode, String) 里的 ErrorCode 仍然是标签。如果要把错误码也变成类型,可以设计一个 LoanFailure 层次:

public sealed interface LoanFailure
        permits LoanFailure.BookMissing, LoanFailure.MemberMissing,
                LoanFailure.AlreadyBorrowed, LoanFailure.LimitExceeded {

    record BookMissing(String isbn) implements LoanFailure {
    }

    record MemberMissing(String memberId) implements LoanFailure {
    }

    record AlreadyBorrowed(String isbn, String memberId) implements LoanFailure {
    }

    record LimitExceeded(String memberId, int activeLoans) implements LoanFailure {
    }
}

这个设计的收益是每个失败都带上了结构化上下文:BookMissing 有 ISBN,LimitExceeded 有会员号和当前借阅数。消费方不需要解析 detail 字符串,也不会出现「详情里写的是 ISBN,代码却按会员号处理」的错位。

代价是构造点变多。原来 new LoanResult.Rejected(ErrorCode.BOOK_NOT_FOUND, isbn) 一行,现在要写 new LoanResult.Rejected(...) 外面再包一个 new LoanFailure.BookMissing(isbn),或者把 LoanResult.Rejected 的组件改成 LoanFailure。后者会破坏既有签名,需要改所有构造点和模式匹配。libraryd 当前没有做这一步,原因不是设计上不认可,而是第 7 章的契约要求 LoanResult 的签名稳定,后续章节还要继续使用。

一个折中方案是保留 LoanResult.Rejected,在它内部提供一个 failure() 方法,把 ErrorCode 和 detail 适配成 LoanFailure:

public record Rejected(ErrorCode code, String detail) implements LoanResult {
    public LoanFailure failure() {
        return switch (code) {
            case BOOK_NOT_FOUND -> new LoanFailure.BookMissing(detail);
            case MEMBER_NOT_FOUND -> new LoanFailure.MemberMissing(detail);
            case BOOK_ALREADY_BORROWED -> new LoanFailure.AlreadyBorrowed(detail, "");
            case LOAN_LIMIT_EXCEEDED -> new LoanFailure.LimitExceeded(detail, 0);
            default -> throw new IllegalStateException("not modelled: " + code);
        };
    }
}

这个适配器本身暴露了问题:default 又回来了,而且 AlreadyBorrowed 需要两个字段,detail 只有一个字符串,只能塞空串或 0 占位。这说明「在旧枚举上贴一层新类型」通常得不到真正的好处。要么一开始就用结构化失败,要么承认 ErrorCode 够用,不要做半套。

在 libraryd 里落地:罚金、动作与展示各一个穷尽 switch

LoanStatePolicy 把状态相关的规则集中起来。它有两个 switch,都没有 default:

package com.plume.library.domain;

public final class LoanStatePolicy {

    private LoanStatePolicy() {
    }

    public static long fineCents(LoanState state) {
        return switch (state) {
            case LoanState.Active active -> 0L;
            case LoanState.Overdue overdue ->
                    overdue.days() * Loan.FINE_CENTS_PER_DAY;
            case LoanState.Returned returned -> returned.fineCents();
        };
    }

    public static String nextAction(LoanState state) {
        return switch (state) {
            case LoanState.Active active -> "return by " + active.dueOn();
            case LoanState.Overdue overdue ->
                    "pay " + fineCents(overdue) + " cents";
            case LoanState.Returned returned ->
                    "closed on " + returned.returnedOn();
        };
    }
}

fineCents 和 nextAction 都是纯函数,输入 LoanState,输出标量或字符串。它们不读取数据库,也不依赖当前时间,因为 LoanState 已经把「今天」的影响算进去了。这样测试可以直接构造三种状态,不需要 mock 时钟。

第二个落点是展示层。LoanStateBadge 把状态翻译成前端用的短标签和颜色:

package com.plume.library.web;

import com.plume.library.domain.LoanState;

public final class LoanStateBadge {

    public record Badge(String label, String tone) {
    }

    public static Badge of(LoanState state) {
        return switch (state) {
            case LoanState.Active active -> new Badge("在借", "green");
            case LoanState.Overdue overdue -> new Badge(
                    "逾期 " + overdue.days() + " 天", "red");
            case LoanState.Returned returned -> new Badge("已归还", "gray");
        };
    }
}

这两个类合起来覆盖了 LoanState 的三条消费路径:算钱、给下一步动作、给 UI 标签。新增 Lost 时,三处 switch 会一起编译失败,作者必须分别决定「遗失状态罚金怎么算」「下一步动作是什么」「前端显示什么」。这正是 sealed 想要的效果:演化成本可见,而不是被 default 隐藏。

常见错误与边界

错误一:为了「以后可能扩展」加 default。 default 会让穷尽性检查失效,新增子类型时编译器不再报错。如果确实需要兜底,把兜底写成一个显式的子类型分支,或者把 default 放在一个单独的方法里,并在方法上标注 @Deprecated,提醒后续维护者复核。不要在核心分支里写 default -> throw new IllegalStateException() 就认为安全:异常要到运行期才抛出,而且可能被上层捕获成 500。

错误二:把 sealed 子类型放在不同包,却忘了同模块约束。 sealed 子类型必须与父类型同模块;如果不同模块,必须同包。把 LoanState.Lost 放到 com.plume.library.web 包,编译会报「类不在允许的子类型列表中」或「sealed 类的子类型必须与父类型同包」。这不是配置问题,是语言规则。

错误三:用 non-sealed 换扩展性,却保留无 default 的 switch。 non-sealed 子类型可以再被继承,封闭集合被打开,编译器不再认为 switch 穷尽,必须加 default。如果代码里仍然没有 default,编译直接失败。non-sealed 不是「稍微放松一点」,而是「放弃穷尽性」;两者只能选一个。

错误四:以为 sealed 能替代运行期校验。 sealed 约束的是类型层次,不是值。new LoanState.Overdue(dueOn, -3) 仍然可以构造,days 为负数不会因为 sealed 而被拒绝。值校验要在构造器里做紧凑构造器检查,例如 Overdue { if (days < 0) throw new IllegalArgumentException(...); }。sealed 管「有哪些形状」,不管「形状里的值是否合法」。

边界一:二进制兼容性。 给 sealed 接口新增一个 permits 子类型,对已经编译的消费方是二进制不兼容的:旧代码的 switch 没有处理新类型,运行时可能抛 MatchException 或走到 default。发布 sealed 类型时,要么把消费方和类型放在同一个发布单元,要么在版本说明里明确标注「新增子类型是破坏性变更」。

边界二:switch 语句与表达式。 表达式 switch 必须穷尽,语句 switch 在 sealed 类型上不要求穷尽,但也不应该依赖这个宽松规则。用语句形式做副作用时,漏分支不会编译失败,只会在运行时什么都不做。把语句 switch 也写成穷尽形式,或者在末尾加一个显式的 default -> throw,让遗漏可见。

小结

  • sealed 接口把「可能的结果」变成封闭集合,permits 列出全部直接子类型,子类型受同模块或同包约束。
  • 穷尽 switch 不需要 default,编译器证明每个子类型都被处理;漏一个就编译失败。
  • 新增子类型会让所有消费方一起失败,这是收益也是发布成本;单仓库演化最舒服,跨团队发布要评估兼容性。
  • ErrorCode 适合标签,sealed 适合形状;把枚举硬贴成 sealed 类型,通常会在适配器里重新长出 default。
  • LoanStatePolicy 和 LoanStateBadge 展示了两类落点:领域规则与展示映射,都应该保持穷尽。
  • sealed 不校验值,负数天数、空 ISBN 仍要在构造器里检查。

下一节处理穷尽 switch 之外的那些细节:case null 到底该放在哪、守卫 when 与分支顺序如何相互影响、多深的模式嵌套会开始伤害可读性,以及 JDK 25 里仍是预览的 primitive patterns。

阅读导航:上一节:7.1 record 模式与解构:领域结果的扁平表达 · 下一节:7.3 模式匹配的边界:守卫、null 与可读性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发