Hacker News new | ask | show | jobs
by gokhan 2197 days ago
What's wrong with this?

  class Result<T>
  {
    bool IsSuccess {get; set;}
    string Message {get; set;}
    T Data {get; set;}
  }
On many occasions, I like using result types for defining a standard response for calls. It's typed and success / fail can be handled as a cross-cutting concern.
7 comments

That's a generic container of 0 or 1 elements ;)

It's also incredibly unsafe and why generics aren't enough. C++, Java, and so on have had generics for ages and with types like the one above, null pointer exceptions are incredibly common. Nothing prevents the user from attempting to retrieve the data without first retrieving the success status.

On the other hand, this improves on it dramatically:

    enum Result<T, E> {
      Success(T),
      Failure(E)
    }
I'm convinced that lack of Sum Types like this in languages like Java/C#/Go are one of the key reasons that people prefer dynamic languages. It's incredibly freeing to be able to express "or". I do it all the time in JavaScript (variables in dynamic languages are basically one giant enum of every possible value), and I feel incredibly restricted when using a language that requires a class hierarchy to express this basic concept.
I completely agree. Every passing day I become more convinced that a statically typed language without sum types or more broadly ADTs is fundamentally incomplete.

The good news is that many languages seem to be cozying up to them, and both the JVM (through Kotlin, Scala, et all) and .net (through F# or C# w/ language-ext) support them.

Even better news is that the C# team has stated that they want to implement Sum Types and ADTs into the language, and are actively looking into it.

I just don't see, in properly designed code, that there would be that much use for sum types if you have generics. When are you creating functions take or return radically different types that need to be expressed this way?

I dislike dynamic languages where parameters and variables can take on any type -- it's rarely the case that same variable/parameter would ever need to contain a string, a number, or a Widget in the same block of code.

I find it much more freeing to have the compiler be in charge of exactness so I can make whatever changes I need knowing that entire classes of mistakes are now impossible.

> When are you creating functions take or return radically different types that need to be expressed this way

Let's say you're opening a file that you think is a CSV. There can be several outcomes:

- the file doesn't exist

- the file can't be read

- the file can be read but isn't a valid CSV

- the file can be read and is valid, and you get some data

All of these are different types of results. You can get away with treating the first 3 as the same, but not the last. Without a tagged union, you'll probably resort to one of a few tricks:

- You'll have some sort of type with an error code, and a nullable data field. In reality, this is a tagged union, it's just that your compiler doesn't know about it and can't catch your errors.

- you'll return an error value and have some sort of "out" value with the data: this is basically the same as the previous example.

- you'll throw exceptions, which usually ends up with people writing code that forgets about the exception because the compiler doesn't care about it, and the code works 99% of the time until it completely blows up.

If you want to force people to handle the above 3 cases, couldn't you just throw separate checked exceptions (eg in Java)? In that case the compiler does care about it. You can still catch and ignore but that imo is not a limitation of the language's expressiveness.
Checked exceptions would have been an ok idea if it weren't for the fact that at least when I was writing Java last (almost 10 years ago) they were expressly discouraged in most code bases. Partially because people just get in the lazy habit of catch and rethrow RuntimeException, or catch and log, etc. when confronted with them. Partially because the JDK itself abused them in the early days for things people had no hope of handling properly.

They also tend to defer handling out into places where the context isn't always there.

The trend in language design does seem to be more broadly away from exceptions for this kind of thing and into generic pattern matching and status result types.

Note that checked exceptions are essentially the same thing as returning a tagged union, from a theoretical perspective at least.

They're not popular in Java though, because the ergonomics is a lot worse than working with a Result type.

Honest question: do you think this kind of stuff is going to be adopted by the majority in the next decade or two? Because I'm looking at it and adding even more language features like that seems to make it even harder to read someone else's code.
um... you realize the parent post is talking about having sum types in statically typed languages (eg. rust), when you already do this all the time in dynamic languages like javascript and python right?

So, I mean, forget 'the next decade or two'; the majority of people are doing this right now; python and js are the probably the two most popular languages in use right now.

Will it end up in all statically typed languages? Dunno; I guess probably not in java or C# any time soon, but swift and kotlin support them already).

...ie. if your excuse for not wanting to learn it is that it's probably an edge case that most people don't have to care about now, and probably never will, you're mistaken I'm afraid.

It's a style of code that is very much currently in use.

Because even with generics, you are not able to express "or"; two different choices of types that have _different_ APIs. With generics, you can express n different choices of types that have all the _same_ API.

It's a good software engineering principle to make control and data flow as streamlined as possible, for similar data. Minimize branches and special cases. Generics help with this, they hide the "irrelevant" differences, surfacing only the relevant.

On the other hand, if there are _actually_ different cases, that need to be handled differently, you want to branch and you want to express that there are multiple choices. Sum types make this a compiler-checked type system feature.

Let's take Rust's hash map entry api[0], for example. How would you represent the return type of `.entry()` using only a class hierarchy?

    let v = match map.entry(key) {
        Entry::Occupied(o) => {
            o.get_mut() += 1;
            o.into_mut()
        }
        Entry::Vacant(v) => {
            update_vacant_count(v.key());
            v.insert(0)
        }
    };
I view sum types as enabling the exact same exactness as you describe in your last line; especially since you can easily switch/match based on a specific subtype if you realize you need that, without adding another method to the base class and copying into the x subclasses that you have for implementing the different behavior.

[0]: https://doc.rust-lang.org/std/collections/hash_map/enum.Entr...

Rust has both generics and sun types, and benefits enormously from both.

And sum types aren’t for “radically different types”. You can define an error type to be one of different options (I.e. a more principled error code), or to represent nullability in the type system, or to indicate fallibility without relying on exceptions, etc.

Rust uses all of these to great effect, and does so because these sum types are generic.

> I'm convinced that lack of Sum Types like this in languages like Java/C#/Go are one of the key reasons that people prefer dynamic languages.

It doesn't hurt that static languages (TypeScript) or tools (mypy) that lightly lay on top of dynamic languages often do support sum types.

> It's also incredibly unsafe and why generics aren't enough. C++, Java, and so on have had generics for ages and with types like the one above, null pointer exceptions are incredibly common.

uh, you'd never get a null-pointer exception in C++ given the type that OP mentioned. Value types in C++ cannot be null (and most things are value types by a large margin).

> Value types in C++ cannot be null

They can just not exist. And C++ being C++, dereferencing an empty std::optional is UB. In practice this particular UB often leads to way worse consequences than more "conventional" null-pointer derefs.

Then write your own optional that always checks on dereference or toggle whatever compilation flag enables checking in the standard library you are using.
Instead you can have undefined behaviour in C++.

Don't think get;set is C++, though it breaks encapsulation.

You can also constrain a generic type only to value types in C#:

  class Result<T> where T: struct
  {
  ...
  }
In that case it can't be null with C# either.
Then you can't construct it unless it's successful, no?

A Result<T> that can only contain successful values doesn't seem very useful

You can, it's possible to address "missing values" with a default construct. Example:

  int x = default; // x becomes zero
  T x = default; // x becomes whatever the default value for struct is
Then we're back to accessing that value being an enormous footgun, yes?
Then you can't construct it unless it's successful, no?

A Result<T> that can only contain successful values doesn't seem very useful

No, you just are forced to use methods like foo.UnwrapOr(default_value) to get the Result. Or depending on the language, you get a compile error if you don't handle both possible values of the Result enum in a switch statement or if/else clause.

See for example https://doc.rust-lang.org/std/result/enum.Result.html#method... in rust, https://docs.oracle.com/javase/8/docs/api/java/util/Optional... in Java, and https://en.cppreference.com/w/cpp/utility/optional/value_or in C++.

Who are you replying to? Is any of your elaboration related to this result type?

    class Result<T>
    {
      bool IsSuccess {get; set;}
      string Message {get; set;}
      T Data {get; set;}
    }
Ah you're quite correct.
Yes you can? The equivalent type in C++ is std::expected[1] which doesn't even contain a pointer that could be dereferenced (unless T is a pointer obviously).

[1] unfortunately not standardized yet https://github.com/TartanLlama/expected

Who are you replying to? Is it in any way related to the original comment I replied to and this type?

    class Result<T>
    {
      bool IsSuccess {get; set;}
      string Message {get; set;}
      T Data {get; set;}
    }
I am replying to you and its pretty obviously related to your comment.

You: "C++, Java, and so on have had generics for ages and with types like the one above, null pointer exceptions are incredibly common."

jcelerier: "you'd never get a null-pointer exception in C++ given the type that OP mentioned."

You: "Then you can't construct it unless it's successful, no?"

Me: "The equivalent type in C++ [to what the OP mentioned] is std::expected". It is not possible to get a null-pointer exception with this type and yet you can construct it.

Well, T can be a pointer / reference here.
That wouldn't change anything to Result<T>'s implicit safety properties. "safe + unsafe == unsafe" - to have a meaningful discussion we should focus on the safe part, else it's always possible to bring up the argument of "but you can do ((char*)&whatever)[123] = 0x66;"
With c# 8 you have nullable references and you can use the compiler to guard you against null pointer exceptions.
> That's a generic container of 0 or 1 elements ;)

Then chances are so are most if not all of the uses of generics OP criticises. The only "non-container" generics I can think of is session types where the generic parameter represents a statically checked state.

Result types are much better than multiple return values. But now the entire Go ecosystem has to migrate, if we want those benefits (and we want consistent behavior across APIs). It'd be like the Node.js move to promises, only worse...
I'm not sure why you'd use a class like this in Go when you have multiple returns and an error interface that already handles this exact use case.
Because multiple return values for handling errors is a strictly inferior and error prone way for dealing with the matter.

    func foo() (*SomeType, error) {
        ...
        return someErr
    }

    ...
    result, err := foo()
    if err != nil {
        // handle err
    }
    // handle result
vs

    type Result struct {
        Err error
        Data SomeType
    }

    func (r *Result) HasError() bool {
        return r.Err != nil
    }

    func bar() *Result {
        ...
        return &Result { ... }
    }

    ...
    result := bar()
    if result.HasError() {
       // handle result.Err
    }
    // handle result

I'm not really sure I see the benefit to the latter. In a language with special operators and built-in types it may be easier (e.g. foo()?.bar()?.commit()), but without these language features I don't see how the Result<T> approach is better.
Go can't really express the Result<T> approach. In Go, it's up to you to remember to check result.HasError(), just like it's up to you to check if err != nil. If you forget that check, you'll try to access the Data and get a nil pointer exception.

The Result<T> approach prevents you from accessing Data if you haven't handled the error, and it does so with a compile-time error.

Even with Go's draconian unused variable rules, I and my colleagues have been burned more than once by forgotten error checks.

there are linters that will help you with that.

https://github.com/kisielk/errcheck

https://golangci-lint.run/usage/linters/ has a solid set of options.

I just wish the linter was integrated into the compiler. And that code that didn't check would simply not compile
> without these language features I don't see how the Result<T> approach is better.

That's the point! I want language features!

I don't want to wait 6 years for the designers to bake some new operator into the language. I want rich enough expression so that if '?.' is missing I just throw it in as a one-liner.

Generics is one such source of richness.

A language with sun types will express Result as Success XOR Failure. And then to access the Success, the compiler will force you to go through a switch statement that handles each case.
The alternative is not the Result type you defined, but something along the lines of what languages like Rust or Haskell define: https://doc.rust-lang.org/std/result/
It's interesting that you say this, because I've had the opposite experience. I wouldn't say it's strictly inferior, because there are definitely upsides. If it was strictly inferior, why would a modern language be designed that way -- there must be some debate right?

I love multiple returns/errors. I find that I never mistakenly forget to handle an error when the program won't compile because I forgot about the second return value.

I don't use go at work though, I use a language with lots of throw'ing exceptions, and I regularly miss handling exceptions that are hidden in dependencies. This isn't the end of the world in our case, but I prefer to be more explicit.

> If it was strictly inferior, why would a modern language be designed that way

golang is not a modern language (how old it is is irrelevent), and the people who designed it did not have a proper language design background (their other accomplishments are a different matter).

Having worked on larger golang code bases, and I've seen several times where errors are either ignored or overwritten accidentally. It's just bad language design.

I cannot think of a language where errors cannot be ignored. In go it is easy to ignore them, but they stick out and can be marked by static analysis. The problems you describe are not solved at the language level, but by giving programmers enough time and incentives to write durable code.
The following line in golang ignores the error:

    fmt.Println("foo")
Compare to a language with exception handling where an exception will get thrown and bubbles up the stack until it either hits a handler, or crashes the program with a stack trace.

And I was referring to accidental ignoring. I've seen variations of the following several times now:

    res, err := foo("foo")
    if err != nil { ... }
    if res != nil { ... }
    res, err = foo("bar")
    if res != nil { ... }
In rust, errors are difficult to ignore (you need to either allow compiler warnings, which AFAICT nobody sane does, or write something like `let _ = my_fallible_function();` which makes the intent to ignore the error explicit).

Perhaps more fundamental: it’s impossible to accidentally use an uninitialized “success” return value when the function actually failed, which is easy to do in C, C++, Go, etc.

Error handling is hard, period. Error handling in go is no worse than any other language, and in most ways it is better being explicit and non-magic.

> people who designed it did not have a proper language design background

Irrelevant.

> It's just bad language design.

try { ... } catch(Exception ex) { ... }

Exceptions don't lead to silent but dangerous and hard to debug errors. The program fails if exception is not handled.
> try { ... } catch(Exception ex) { ... }

The error here is explicitly handled, and cannot be accidentally ignored. Unlike golang where it's quite easy for errors to go ignored accidentally.

I would say it is a very ergonomic way of doing this. It allows for writing in a more exploratory way until you know what your error handling story is. Then, even if you choose to propagate it later, you just add it to your signature. Also it is very easy to grok and clear. Definitely not strictly inferior.
It's a lot cleaner to pass a Result<T> through a channel or a slice than to create two channels or slices and confirm everyone's following the same convention when using them.
I concede that there are probably scenarios where this design makes sense within that context. I typically find that either I care about a single error and terminating the computation, or I don't care about errors at all. In the former case, the primitives in the sync package (or just an error channel which we send to once and close) are adequate. The latter case presents no issues, of course.

At $work we definitely have examples where we care about preserving errors, and if that tool were implemented in Go a solution like a Result struct containing an error instance and a data type instance could make sense.

It has a bunch of invalid states (message and data both set, neither set, message set but IsSuccess is true, etc.). So you have to either check it every time, or you'll get inconsistent behaviour miles away from where the actual problem is. It's like null but even more so.
Well, for one thing, it doesn't actually work like a proper Optional<T> or Either<T, string> type. It works more like Either<(T, string),(T, string)>, which might have some uses, but isn't typically a thing someone would often reach for if they had a type system that readily supported the other two options.
> What's wrong with this?

That it's mutable, at the very least!

I feel like such a class should either be part of the language, and part of language idioms etc, or it shouldn't be used.
Can you articulate why? it seems to me that 'feel' should not be part of the discussion.
Not GP, but I've sometimes found libraries implementing similar concepts differently causing issues.

E.g.

    libraryA.Result struct {
        Err error
        Data SomeDataType
    }

    libraryB.Result struct {
        err string
        Data SomeDataType
    }
    func (r libraryB.Result) Error() string {
         return r.err
    }
Now you have two different implementations of the same fundamental idea, but they each require different handling. In Go, where many things simply return an error type in addition to whatever value(s), you would now have three different approaches to error handling to deal with as opposed to just whatever the language specified as the best practice.
This is what interfaces are for.

Let your caller bring their own error type and instantiate your library code over that.

Not GP but:

It may frustrate coworkers who need to edit the code.

It adds another dependency into your workflow.