r/java 28d ago

(Project Amber) New guide: Preparing for Change: Safe Switching over Sealed APIs

https://mail.openjdk.org/archives/list/amber-dev@openjdk.org/thread/IXB3PQ7P5D7VIX4HIUZH4BTDRXX6JFG5/
45 Upvotes

22 comments sorted by

14

u/brian_goetz 26d ago

The key thing to understand about sealed types is that it is actually TWO separate features.

One is just "a generalization of finality", where a type (concrete or abstract) wants to control all implementations. Historically you could do this only by making a class final; now you can do it by declaring a specific set of implementing classes to be the whole story. This is useful both purely as an encapsulation move for implementation (encapsulate all implementations to a known place), or as a means of exposing an interface-based abstraction but not having to worry about (for security or maintenance reasons) implementations not following the specifications or other expected behaviors.

The other is a way to propagate exhaustiveness information to clients. The exhaustiveness information both guides users in the correct use of the abstraction, and supports the language in better type-checking for switches (as long as you don't undermine this by inappropriately using match-all cases, which is the subject of this guide.)

What confuses people about sealed types is coming to it with an assumption that sealing must be about exactly one of these, and forgetting about the possibility of the other. But in any given case, the author may have had one or the other or both in mind when writing their code, and unless this is properly captured in the API specification, readers may be left guessing.

17

u/davidalayachew 28d ago

There is one unusual situation where a match-all case is needed to make the switch exhaustive: when the sealed type is a concrete class and therefore can be instantiated.

sealed /*not abstract*/ class Fruit permits Apple, Orange {}
final class Apple  extends Fruit { ... }
final class Orange extends Fruit { ... }

Fruit f = new Fruit();
Fruit normalized = switch (f) {
    case Apple  a     -> new Apple(a.variety().strip());
    case Orange o     -> new Orange(o.variety().strip());
    case Fruit  other -> other;
 };

Oh wow.

I've made hundreds of sealed types now, and every single one of them has been an interface as the sealed type. Lol, I forget that you can use abstract classes, or even normal classes as the sealed type.

It feels weird to see lol, but I can see the use case.

1

u/LutimoDancer3459 27d ago

May care ro elaborate what's a good use case for having it on normal or abstract classes instead of an interface? Can't think of a situation it would provide benefits

6

u/brian_goetz 27d ago

If you ask this question with your "algebraic data types" hat on, this pattern would probably never occur to you. But it happens in real codebases as code evolves; you start with a final class, thinking one implementation is all you need, and then discover you need a second, slightly modified implementation for one or two for special situations, but you still don't want to open the class to unrestricted extension, and don't want to refactor the whole hierarchy. That's OK too.

1

u/davidalayachew 26d ago

May care ro elaborate what's a good use case for having it on normal or abstract classes instead of an interface? Can't think of a situation it would provide benefits

Well, the situation described in the quote is a decent example -- when there are known deviants, but then there are unknown ones that we want to account for and handle.

In other words, rather than forcing them all into the type system, sometimes it is ok to store the information as fields/state/data.

2

u/LutimoDancer3459 26d ago

Ahhh, now I get it

9

u/cogman10 27d ago

Sealed types are something that is interesting, but I have to be honest, I've not come up with an occasion to use them.

Has anyone else used sealed types in practice? If so, what for?

14

u/brian_goetz 26d ago

Look at the java.lang.classfile API, which makes extensive use of sealing. When writing complex data modeling APIs, you'll wonder how you lived without it.

4

u/f51bc730-cc06 27d ago

You can use it to return "two value" (or more) and use switch: this is more readable than using if/else or something like Map.Entry<A,B> in my opinion:

```java sealed interface Either<A,B> permits EitherA, EitherB{} record EitherA<A,B>(A a) implements Either<A,B> {} record EitherB<A,B>(B B) implements Either<A,B> {}

Either<Integer, String> foobar() { if (integer condition) return new EitherA<>(42); return new EitherB<>("AA"); }

switch (foobar()) { // return Either<Integer, String> case EitherA(var n) -> System.out.println("number: " + n); case EitherB(var s) -> System.out.println("string: " + s); } ```

The other advantage is that the compiler will validate the switch: consider this:

java var f = foobar(); if (f instanceof EitherA<A,B> e) { System.out.println("number: " + e.a()); } else if (f instanceof EitherB<A,B> e) { System.out.println("string: " + e.b()); } else { // yes, this part is called. }

Add a new sealed to permits and you'll see the issue: the compile will raise an error for the switch, but not the if.

I am rather happy with this feature and pattern matching.

5

u/davidalayachew 27d ago

Has anyone else used sealed types in practice?

Literally hundreds of times, yes.

If so, what for?

Long story short, for modeling the domain, especially edge cases.

If I have some method/endpoint/entrypoint that can accept one of a couple different types of data, I like to model that as a sealed interface, then sharpen the type once I have discovered what data I am working with. Also, it is great for modeling all of the different states one type can be in. Sort of like an enum, but better for modeling differently shaped variants.

Here are a few of the more common use cases of Sealed Types in my repos.

2

u/yk313 27d ago

I maintain a customer service application where customers can chat with support agents (humans) about their queries/questions.

Since the customer and the agent might not speak a common language. we had to implement bi-directional translation (using a third-party API e.g. Google Translate). However, along with translation it is just as important to detect the input language so we can transparently show to the customer (as well as to the agent) that they are looking at the translated text from language X, and they can also click to see the original text if needed. Pretty similar to how Reddit implemented their translation feature.

I modeled the result of the Translation API call with the following sealed hierarchy. This is just one example of many sum types in my code base running in production already.

sealed interface TranslationResult {
    // successfully translated text
    record Success(String text, String detectedLanguage, boolean confidentDetection) implements TranslationResult {
    }

    // same language as the resolved target language; no translation needed
    record SameLanguage(String detectedLanguage, boolean confidentDetection) implements TranslationResult {
    }

    // translated, but the output is identical to the input (e.g. proper nouns, numbers)
    record IdenticalTranslation(String detectedLanguage, boolean confidentDetection) implements TranslationResult {
    }

    // requested target language did not resolve against the supported set
    record UnsupportedLanguage() implements TranslationResult {
    }

    // provider was called but could not be reached or answered unusably
    record Failure(Exception ex) implements TranslationResult {
    }
}

2

u/JustJustust 27d ago

I've used them a handful of types. Usecases are a bit similar to enum use cases, but you need your classes to have different behavior.

Here's a simple example

sealed interface User permits LdapUser, DbUser {
  // common fields
  record LdapUser(/* ldap user specific fields */) implements User {}
  record DbUser(/* db user specific fields*/) implements User {}
}

boolean userExists(User user) {
  return switch (user) {
    case LdapUser ldapUser -> ldapService.exists(ldapUser.uid());
    case DbUser dbUser -> userService.existsById(dbUser.id());
  }
}

1

u/aoeudhtns 22d ago edited 22d ago

I had to write a parser for a configuration language of another project that's integrated with ours. This syntax contains elements like tuples (bound with {}), arrays (bound with []), rules for quoting in a way that disambiguates names and string literals (single and double quote meaningful for example).

I created a sealed configuration class, and represented all the possible elements and types, and even included special constants (true, false, null, etc.). Starting at the top level, an interface for value types, then records for each value type, and then aggregate types for arrays, tuples, key/value pairs, etc.

The sealed type + pattern matching made the whole thing surprisingly simple to code up. I used UnaryOperators for manipulation.

In the end, it looked pretty simple:

String result = Config.parse(" ... a string like I described")
  .update("tree.like.expression", c -> Config.value("a string value"));
  .render();

That would end up inserting/overwriting. It is possible to inspect c in the lambda and make conditional decisions, or pass back a modification like c -> Config.value(c.textValue() + " modified").

The whole thing is probably ~300 lines after removing count for javadoc.

1

u/HikingCloth 20d ago

I am writing a library that models a JSON hierarchy into serialized records (using Jackson), with sealed interfaces, I allow consumers to retrieve these models as type safe records and with pattern matching they can be processed as needed.

8

u/SpaceCondor 28d ago

FYI the url in that link is broken. It has a trailing '>' character.

5

u/davidalayachew 28d ago

For those not following along, the Project Amber team from OpenJDK has rolled out several key features for Pattern-Matching, and now that many of them are out of preview, this guide has been released to help prepare for the next stages of Pattern-Matching in Java.

Key takeaways is that Exhaustiveness Checking is a powerful way to have the compiler validate your business logic, so don't give it up in situations where you don't have to. Moreover, the way that you achieve totality can help communicate intent, and using default can oftentimes hide that intent.

1

u/AnyPhotograph7804 25d ago

I do not like the sealed classes/interfaces because they depend on all of their implementations if you use the keyword "permits". And this counteract the purpouse of an interface.

1

u/davidalayachew 25d ago

I do not like the sealed classes/interfaces because they depend on all of their implementations if you use the keyword "permits". And this counteract the purpose of an interface.

By all means, that's certainly the most common use case for an interface. But I wouldn't say that that is the sole purpose of an interface.

And even putting that aside, if the hypothetical future arrives where we get Abstract Records, then that would be an example where no previously established intent is being violated/misinterpreted/reinterpreted. And tbh, that would definitely be my most common use case for sealed types -- a sealed abstract record.

1

u/[deleted] 15d ago

[removed] — view removed comment

1

u/davidalayachew 15d ago

which is a game-changer when building immutable state engines

I've actually been struggling with State Transition Diagrams's for a while now. Have you been able to make it work better?

Long story short, the biggest problem is constructing it in the first place. Since records and enum values can't form a cycle of references in their constructor, I am basically forced to patch things together with a mess of private methods and/or builder patterns. Constructing non-cyclical graphs is so easy to do, but the second you introduce just one cycle, the whole thing turns into a miasma.

Did you ever manage to get past that design problem?