Smart cast for two subclasses of a sealed class?

I think it would be useful if the compiler could work on sealed case classes with only two implementing classes in a similar way to checking for null and smart casting after the check:

sealed class Something {
    class SomeA : Something()
    class SomeB : Something() {
        fun smartCast(): String = "Smart cast"
    }
}

fun test(sth: Something): String {
    if (sth is SomeA) return "A"
    return sth.smartCast()
}

Of course this doesn’t work at the moment, but when the function did not return after checking if sth is SomeA then sth is of type SomeB, right?

I don’t know how deep you want to go into “x is deducible from the current situation, therefor you don’t have to explicitly state x” style smart-casting. It doesn’t exactly make for the most readable code.
Reading if( x is SomeB) return x.smartCast() is pretty clear exactly what it does and why (especially since, ideally, the method smartCast should have something to do with WHAT SomeB is. But reading if( x !is SomeA) return x.doWhatSomeBDoes(), the code is pretty obtuse even if you’re fairly familiar with the fact that there are only two types of Somethings.

However, what Kotlin CAN do with sealed classes is view them as a “complete” (i.e. covering all cases) when statement allowing you to use it as an determined value:

sealed class Something
class SomeA : Something()
class SomeB : Something() {
    fun smartCast(): String = "Smart cast"
}

fun test( sth: Something) = when( sth) {
    is SomeA -> "A"
    is SomeB -> sth.smartCast()
}

Yes, but if say SomeA is actually called Invalid and SomeB is Valid then reading something like

if (result is Invalid) return result.errors.first()
return result.value

makes sense to me, because when the method did not return, the result is not Invalid and therefore must be Valid making the happy path the path least nested and/or obscured by syntax.

But yeah, I see that this can be easily misused.

1 Like

I can see that. I personally prefer to read and write comprehensive whens, but null smart-casting already work with implicit scopes, so it’s not unreasonable to expect enums/sealed classes to behave the same way.

1 Like

Well with Nullability it is guaranteed to be one of 2 cases. I agree the compiler could check whether or not the enum/sealed class has only 2 implementations, but that would mean you can make a lot of code invalid by just adding a third value to an enum, which would be really bad for library development as you can no longer extend your enums/sealed classes without breaking backwards compatibility.

1 Like

To be fair, is there any way to expand a sealed class without breaking things that use the fact that it’s sealed? Adding a new Sealed type will break a when statement (though perhaps in a more intuitive way that is easier to fix) and an if…else sequence without a fallback return. And if you aren’t using those then what benefit are you get out of declaring it sealed rather than just final?

The keyword is called “sealed” for a reason. While adding a new Sealed type is certainly a realistic situation, if code is expecting there to only be 3 different Sealed types, isn’t the whole point that it breaks at compile time when you add a 4th so that you can handle the new type?

2 Likes

I have just stumbled into exactly the same situation. I’m coding a Result-like class (I call it Outcome because Result is already taken), and I’d really like to be able to write code like

val id: Outcome<Int> = validateId()
if (id is Failure) {
    // display an error message
    return
}
id as Success // I'd like to get rid of this...
val name: Outcome<String> = validateName()
if (name is Failure) {
    // display an error message
    return
}
name as Success // ... and this
val myObject = MyClass(id.value, name.value)

This isn’t exactly rewritable with when. Maybe it can be fixed is by adding some kind of onSuccess and onFailure inline methods to Outcome and then chaining them. But to have a way to write it elegantly in imperative style would be nice too.

1 Like

Does it work this way?

if (id !is Success) {
    // display an error message
    return
}
id as Success // should be unnecessary now
....
1 Like

It would work, but I simplified the example too much. It actually contains code like this:

val id: Outcome<Int> = validateId()
if (id is Failure) {
    // display an error message
    view.showErrorMessage(id.firstError.message)
    return
}

Here, firstError is a member of Failure, so it would work with !is Success check, but only if the topic feature were implemented (and is Failure would then work too, of course).

1 Like

I honestly thought that this feature was already implemented. Weird lol. Anyways, for the time being, there’s a small workaround. You could make 2 extension functions that follow this pattern:

fun Outcome<*>.isSuccess(): Boolean {
    contract {
        returns(true) implies (this@isSuccess is Outcome.Success && this@isSuccess !is Outcome.Failure)
    }
    return this is Outcome.Success
}

(for the isFailure one just replace Success with Failure and vice versa)
Note: I haven’t tested this, but AFAIK it should work just fine.

2 Likes

But contracts are experimental, and that may or may not be a deal breaker.

I’m currently trying to rely on onSuccess/onFailure inline methods instead, which sometimes even improves readability, but it would be nice to have smart casts too.

1 Like

Contract’s binary implementation is stable, and so depending on any module that uses them is completely fine. The worst-case scenario is that the contracts dsl itself might change, and so you will just have to use the new representation just in those 2 methods (e.g. you might need to move from using a dsl block to using annotations). The semantics of contracts is also stable, which means that any compile-time verification or guarantees that they give you will not suddenly break.

1 Like