How and when to use Kotlin sealed classes in Java?

Viewed 1255

Thinking about misusing sealed classes, let's look at the following design.

There are two modules:

  • parser (Kotlin) - is responsible for making instances from String
  • processor (Java) - pumps over raw incoming data into a strongly typed storage (i.e. relational tables)
  1. String comes from external source to processor
  2. processor delegates its recognition to parser
  3. parser makes instances of different types [Banana, Nail, Shoe] based on some rules X
  4. processor persists each instance into an appropriate table based on some rules Y

Is it appropriate to use sealed classes here like this in parser and after that in processor make decisions base on concrete type of each instance?

// parser module exposes Item and its subclasses

sealed interface Item {
    class Banana(/*state 1*/) : Item
    class Nail(/*state 2*/) : Item
    class Shoe(/*state 3*/) : Item
}

fun parse(value: String, rule: ParseRule): Item {
    return when (true) {
        rule.canParseBanana(value) -> rule.makeBananaFrom(value)
        rule.canParseNail(value) -> rule.makeNailFrom(value)
        rule.canParseShoe(value) -> rule.makeShoeFrom(value)
        else -> throw RuntimeException("cannot parse")
    }
}    

// processor module makes decisions based on class 

void process(String value){
  Item item = parser.parse(value);

  if (item instance of Item.Banana){
    persistBanana((Item.Banana) item)
  } else if ( ... )
    // etc         
  } else {
     throw new RuntimeException("Unknown subclass of Item : " + item.getClass())
  }
}

I see that something is wrong in this approach, because growing number of Item's subclasses could lead to a design catastrophe, but cannot figure out whether is there a "canonical" use case of sealed classes sufficiently different from this one.

What is the limit of sealed classes applicability, when system designer should prefer something "less typed" like the following:

class Item{
  Object marker; // String or Enum
  Map<String, Object> attributes;
}

// basically it is the same, but without dancing with types
void process(String value){
    Item item = parser.parse(value);

    if ("BANANA".equals(item.marker)){
      persistBanana(item.attributes)
    } else if (...){
      // etc
    }
}
2 Answers

You can use the visitor pattern to provide a when..is style approach to Java.

// Kotlin
abstract class ItemVisitor<OUT> {
    operator fun invoke(item: Item) = when (item) {
        is Banana -> visitBanana(item)
        is Shoe -> visitShoe(item)
        is Nail -> visitNail(item)
    }
    abstract fun visitBanana(item: Banana): OUT
    abstract fun visitShoe(item: Shoe): OUT
    abstract fun visitNail(item: Nail): OUT
}

Because Item is sealed you don't need the else case in that when and then Java code can create a visitor instead of doing its own instanceof checks with an else, and if you add a variant, you add a method and get reminded that your Java implementations of the visitor need the new method.

If you choose to use sealed classes you take it upon yourself to extend the hierarchy whenever a new subtype is introduced. Given that you have a subtype specific rules and persistence I am not sure how much you can do to avoid this.

Having said that, it's a shame to do instanceOf after you already determined the item type. The code below is more or less visitor pattern(this is disputed, see comments). I assume the separation to Java and Kotlin is required.

// Kotlin
sealed interface Item {
    class Banana(/*state 1*/) : Item
    class Nail(/*state 2*/) : Item
    class Shoe(/*state 3*/) : Item
}

class Parser(val persistor: Persistor, val rule: ParseRule) {
    fun parse(value: String): Item =
        when (true) {
            rule.canParseBanana(value) -> rule.makeBananaFrom(value).also { persistor.persist(it) }
            rule.canParseNail(value) -> rule.makeNailFrom(value).also { persistor.persist(it) }
            rule.canParseShoe(value) -> rule.makeShoeFrom(value).also { persistor.persist(it) }
            else -> throw RuntimeException("cannot parse: $value")
        }
}

class ParseRule {
    fun canParseBanana(value: String): Boolean = ...
    fun makeBananaFrom(value: String): Item.Banana = ...
    ...
}
// Java
public class Persistor {
    void persist(Item.Banana item) {
        System.out.println("persisting " + item);
    }
    void persist(Item.Shoe item) {
        System.out.println("persisting " + item);
    }
    void persist(Item.Nail item) {
        System.out.println("persisting " + item);
    }
}


public class Processor {
    private final Parser parser;

    public Processor(Parser parser) {
        this.parser = parser;
    }

    void process(String value) {
        parser.parse(value);
    }
    
    public static void main(String[] args) {
        Parser parser = new Parser(new Persistor(), new ParseRule());
        Processor p = new Processor(parser);
        p.process("banana");
        p.process("nail");
        p.process("zap");
    }
}


Related