How would you design a typed error system around t...
# arrow
d
How would you design a typed error system around the following use case? Let's say that I'm building an API around document management which provides the functionality of getting info (metadata) about a document and downloading that document. The metadata and document itself are stored in separate backend systems which might both fail so I want to have a separate error type for each case, e.g. MetadataInternalError and DocumentInternalError (I might not propagate these internal errors to the user but might use them e.g. for centralized logging). On top of that, the requested document might also not exist. In that case, I want to use a common DocumentNotFound error. Technically, each of these errors would be implemented as a data class/object. The question is how to combine these 3 error classes into respective domain wrappers, i.e. MetadataError and DocumentError. Since the concrete error types are classes I obviously can't do
Copy code
class MetadataError : MetadataInternalError, DocumentNotFound
I think (and please correct me if I'm wrong) that this very naturally leads to union types where the domain wrapper would just be a union of the respective error components. Since Kotlin doesn't (and probably won't) have union types, what are my options? Currently, I'm doing this
Copy code
interface MetadataError
interface DocumentError

class MetadataInternalError : MetadataError
class DocumentInternalError : DocumentError
class DocumentNotFound : MetadataError, DocumentError
Basically, I'm tagging each concrete error type with a list of domain types. Besides this design being sort of upside down (IMO) it leads to issues when one wants to add additional methods (such as the mentioned centralized business logging) to the domain types (they cannot have the same name because DocumentNotFound inherits from both domain types). How to best approach this in current and possibly future Kotlin?
c
I have done the same as you're doing. I'm not aware of a better solution.
With context parameters, you will be able to simplify this, but that's not available yet. Without context parameters, you will be able to raise multiple different types from a single function, so you will be able to:
Copy code
interface StandardError
interface MetadataError
interface DocumentError

class DocumentNotFound : StandardError
class MetadataInternalError : MetadataError
class MetadataDocumentError : DocumentError

context(_: Raise<StandardError>, _: Raise<MetadataError>)
fun loadMetadata(…): …

context(_: Raise<StandardError>, _: Raise<DocumentError>)
fun loadDocument(…): …
d
But context parameters don't help with the common domain methods. I would have to put e.g.
logMetadataError
as private fun directly inside
loadMetadata
. In my case (which is slightly different) this might be ok but in general it seems to be a poor design.
c
Sorry, I'm not sure I understand. What is the situation in which context parameters don't help?
d
Ideally, I would like to have something like this:
Copy code
data class MetadataError(val error: MetadataInternalError | DocumentNotFound) {
  fun logError()
}
c
Copy code
sealed class MetadataError {
    data class MetadataInternalError(…) : MetadataError()
    data class DocumentNotFound(val error: DocumentNotFoundError) : MetadataError()
}
😕
d
But I need the common
logError
method and as I said I cannot add it to both
MetadataError
and
DocumentError
because of
class DocumentNotFound : MetadataError, DocumentError
. Or perhaps I'm misunderstanding your example.
Anyway, I think that without proper union types the situation will never be ideal. One will always need to resort to some brain twists 😞
p
it's horrible:
Copy code
fun log(m: String) = println(m)

data object DocumentNotFound(val id: String)
data class MetadataInternalError(val message: String)

data class MetadataError private constructor(private val error: Any) {
    constructor(dnf: DocumentNotFound): this(error = dnf)
    constructor(mie: MetadataInternalError): this(error = mie)
    fun logError(): Unit = when(error) {
        is DocumentNotFound -> log("Missing document id=${error.id}")
        is MetadataInternalError -> log("Failed to retrieve metadata: ${error.message}"
        else -> error("unexpected error type")
    }
}
as for having a common
logError
why not lift to a common interface for both error interfaces?
Copy code
interface LoggableError {
    fun logError()
}

interface MetadataError : LoggableError
interface DocumentError : LoggableError

class MetadataInternalError : MetadataError {
    override fun logError() = println("I am MetadataInternalError")
}

class DocumentInternalError : DocumentError{
    override fun logError() = println("I am DocumentInternalError")
}

class DocumentNotFound : MetadataError, DocumentError {
    override fun logError() = println("I am DocumentNotFound")
}

fun main() {
    MetadataInternalError().logError()
    DocumentInternalError().logError()
    DocumentNotFound().logError()
}
d
it's horrible
Of course, with unsafe typing you can do many things but that's not what we are after 🙂
p
arguably the unsafe typing is constrained within the type itself in the same way the compiler would for union-types-by-erasure - just takes more care to achieve
d
why not lift to a common interface for both error interfaces
Because the method should be specific to the domain type, not the concrete errors. Ideally:
Copy code
union class MetadataError {
  MetadataInternalError, DocumentNotFound; // similar syntax as for enums

  fun logError() = println("I am MetadataError")
}

union class DocumentError {
  DocumentInternalError, DocumentNotFound; // similar syntax as for enums

  fun logError() = println("I am DocumentError")
}
arguably the unsafe typing is constrained within the type itself
I understand that. My mind just somehow refuses to reimplement union types mechanism in each place where it's needed 🙂
p
Copy code
sealed interface MetadataError
sealed interface DocumentError

class MetadataInternalError : MetadataError
class DocumentInternalError : DocumentError
class DocumentNotFound : MetadataError, DocumentError

fun MetadataError.logError() = println("MetadataError:$this")
fun DocumentError.logError() = println("DocumentError:$this")
                                       
fun main() {
    MetadataInternalError().logError()
    DocumentInternalError().logError()
    // DocumentNotFound().logError() // ambiguous
    (DocumentNotFound() as DocumentError).logError()
    (DocumentNotFound() as MetadataError).logError()
}
extension methods maybe?
you'll never solve the ambiguity when it could be both DocumentError and MetadataError
d
Yes, not in the current language. That's why I think union types are irreplaceable despite people claiming they are not necessary thanks to sealed classes. I think this comment sums it up quite nicely: https://youtrack.jetbrains.com/issue/KT-13108/Denotable-union-and-intersection-types#focus=Comments-27-4717115.0-0
Especialy, the statement "Union types in Kotlin could simply be syntax sugar ... much like
data class
is mostly a code generation tool to hide boilerplate"
c
Lol that's what I thought, you're referring to my comment 😅
🤣 2
The Kotlin team has been clear that untagged union types won't be a thing. But I would like tagged union types (anonymous sealed classes)
d
So your
union class
proposal would be feasible?
c
I think, but they haven't really answered.
It would often use wrapping types though.