https://go.dev/play/p/WbGeW8wSu0X
(Ignore the obvious SQL injection issues in that query, thx :)
It takes a lot to make me mad, but this inane behavior manages it. This behavior, singularly, makes it nearly impossible to achieve higher levels of ergonomic safety on the types of errors functions can return. You basically always have to just return the `error` type, and require callers to do runtime reflection.
And this can rear its ugly head all the time. You've got deep functions that are super specific and know exactly what kinds of errors they can return. You've got higher-layered functions that call lots of things that all return different error types, so those may just return `error`. Turns out; they all just have to return `error`, because if you make them more specific, at the freakin TYPE DEFINITION, a segment of the syntax that other sane languages would say is "compiled away" and "not relevant to runtime behavior", you lose any ability to guarantee nil comparisons will work the way higher-level callers expect.
https://go.dev/play/p/fb0e_4loDBf
Its a problem in any situation where a function broadens the type of a struct pointer its returning to an interface. Its just most commonly encountered with errors, because you can't just return `DatabaseError` there, because the zero-value is non-nil and everyone checks errors with `if err != nil`.
The internal Go style guide of a billion dollar tech company every single person reading this has heard of reads: Never return pointers to structs designed to be used as errors. Its a significant, real problem. Its antithetical to any reasonable understanding of how this code should work. Its antithetical to even unreasonable understandings. Blog posts which explain it start with "this makes sense when you understand how reflection works on interface-fulfilling pointer values" then launch into a twenty paragraph graduate thesis as if Go wasn't explicitly designed to help fresh-out-of-college engineers write productive and performant code for Google.
I like Go; but their recent statement that Go will never break backward compatibility genuinely scares me because I'm not sure they can fix this without breaking existing code; which means it may never get fixed. I'm doubtful the designers even consider it a bug. Just... ugh.
This is a common mistake when coming from exception-based-error-handling languages where exceptions are differentiated by type. In Go, if you want a more granular distinction between different kinds of errors, you don't use types, you use values: https://go.dev/play/p/ddzhAqRgK_1
If you insist on using the typesystem for granular error checking, then you can define an "Is(target error) bool" method on your custom type to differentiate in the same way: https://go.dev/play/p/gZmYgOq6wSo
Per https://github.com/golang/go/wiki/CodeReviewComments#interfa...:
Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values. The implementing package should return concrete (usually pointer or struct) types
There clearly is this one exception ONLY for the `error` interface. This advice plus the weird runtime check conventions like `Is` / `As` are nowhere to be seen for other interfaces in the ecosystem.Go makes a lot of effort so that errors are special. There is an exceptional tuple-type that exists solely for returning errors, and the error-return-interface convention breaks interface-return conventions. If they implemented sum-types, then we would have the typical `Result<T, Error>`, and then go programs would have much more invariants and be much more composable. But presumably compilation- and/or run-time would be worse.
Another alternative would have been adding syntax sugar like rust's `?` for `if err != nil { return ..., err }`, and `match err { case CustomError: ... case error: ... case nil ... }`. And some helpers for composing/piping functions.
- There are genuine use cases where returning both a value and an error makes complete sense. My favourite example is doing I/O: some bytes were copied, but there was a problem with the rest.
- It has significant overlap in scope & functionality with interface types. You could have "type Color enum { Named(string); RGB(int, int, int) }", or you could have "type Color interface { RGB() (int, int, int) }; type NamedColor string;" etc and it's not very clear which style you'd be supposed to use, and in what case.
The first problem could be somewhat alleviated by having very special result types that can have both a return value and an error, but these would have to be different from regular "variant" results, so you'd end up either with two ways to do a very similar thing, or yet another layer of abstraction.
The second problem is fundamental to the design of the language, and I would absolutely *hate* to see Go go through the same unholy mess as Python: "old-style" vs "new-style" classes, dataclasses (with third-party "attrs" as a stepping stone), protocols/generic containers, "match"/destructuring, all of these were clumsily grafted on and code that mixes all of these styles can be found all over the place. You do need to make ADTs and pattern matching a first-class citizen in 1.0, otherwise the place you end up in is not pretty at all.
Go? Yeah, we have a lot of SortIntegers([]int) and SortStrings([]string) and SortByName([]Person) in older code, but that's about its biggest sin. As of 1.21, slices.Sort & slices.SortFunc are in std and fixing this older code is pretty much a mechanical task. It's not perfect, but at least it's not worse by trying too hard to be perfect.
I don't feel anyone is saying that the addition of sum types would have to replace the existing (Result, error) pattern. Its just a tool in the toolbox. In fact, I think having both is really interesting from a function signature communication perspective; if a function returns (Result, error) in a post-sum-type world, that hints to me that the Result might still be useful even if an error is returned.
> I would absolutely hate to see Go go through the same unholy mess as Python: "old-style" vs "new-style" classes,
Anytime someone brings up how horrible the Py2 to Py3 transition was, I will remind them that Python is the most popular programming language in the world. Clearly; the transition wasn't actually bad enough to negatively impact its popularity; yet its the token example of "don't break existing programs, you don't want to be like Python".
That take is just counter-intuitively wrong. Its like the SDLC paradigm of "releasing often reduces bugs"; it feels wrong, but its actually right. I'm sure saying "we'll never break existing programs" imparts a nice warm feeling in your heart, "we're mature, unlike those other dumb languages". But there is extremely little evidence that backward-incompatible language changes hurt the adoption of programming languages. Actually; there's substantially more evidence that languages which can't adapt and evolve to change will eventually die.
I'm not asserting that will happen to Go; I think they're good about bringing forward enhancements to the language in ways that don't break existing programs (like generics). But I also strongly believe they need to rethink their "we won't break existing programs" rule. I am begging the Go team to break my programs. Its not a big deal. I just can't imagine still writing Go programs in 2050 like we are today; it leaves so much value on the table, and we can do better.
How do you reconcile that with the general rule or convention that `if err != nil` the value is unsafe to use?
Apart from this, I don't think sum-types should be added this late either. I should have written "if they had implemented". "old vs new style" does more damage than good. Today, it is what it is.
What I do think is feasible is adding some more syntax sugar around errors.
By reading the documentation: https://pkg.go.dev/io#Reader
Conventions are not laws, and should not be followed blindly.
> What I do think is feasible is adding some more syntax sugar around errors.
The most common example I hear is something like Rust's `?` operator for propagating errors. In Go, errors should almost always be expanded with additional context, and programmers should think hard about errors and how to handle them. Syntax sugar would just make it easier for people to ignore errors. And you can't ignore errors forever - they always come up, most often in the place you least expect them.
I'm convinced this is just a very poor way to cope with the lack of stacktraces.
I can't count how many times I've worked with some library in ${LANGUAGE_WITH_EXCEPTIONS}, and had an exception print out a giant stack trace, only for me to realize that the stack trace is useless because the place where exception is raised isn't the place where the error actually happened, but is actually some wrapper/worker collector/other kind of indirection mechanism. In other words, stack traces are directly bound to call stacks, and call stacks don't necessarily contain all relevant context of an error - they only do so in case of simple, single-threaded programs.
Golang-style error-as-values actually provide real, human-curated context that is relevant to the operation at hand. They can be passed between goroutines, and are completely independent of any call stacks. That, in my opinion, makes them vastly superior to stack traces.
Regarding wrapping errors, if you don't provide a stacktrace or at least file:line, how do you actually map the error to source code? Do you just grep and pray that the message is unique enough?
If you're new to the codebase, yes, grepping can get you a long way.
In my experience, just following the error messages from the top (usually errors are printed only in the main function) is enough. Hypothetical example, if you have a file sync program, and you get an error "file sync failed: device Foo unreachable: connect error: quic://123.45.67.89/ i/o timeout", you can already mentally map where exactly the error has happened. The exact file and line should be easy to locate.
Of course, if a codebase doesn't write descriptive enough errors, or just propagates them without context, error diagnosis is going to be difficult. But that's why Go encourages people to think about errors and not ignore them.
It's a contrived problem, at least. In reality, your code is going to look more like:
func GetUserByID(id string) (string, error) {
s, err := QueryDatabase(fmt.Sprintf("select * from users where id = %v", id))
if err != nil {
// Do something with the error, returning a new error if necessary.
return "", someNewErr
}
return s, nil
}
The callers of GetUserByID have absolutely no concern for the implementation details of QueryDatabase. When requirements change and you replace QueryDatabase with QueryWebService, callers expect to still get the same errors back, not HTTP errors all of a sudden when the code was previously getting MySQL errors. That would be plain horrible API design. So, you wouldn't actually ever encounter this particular issue in practice (assuming nobody hates you).But the issue is that the source of the problem is in lower-level code which made the theoretically correct decision to be specific what you return and generic in what you accept. That lower-level code cannot guarantee that its higher-order callers will wrap the errors it returns. So; the lower-level code has to remain generic.
Obviously my example is contrived; but the problem is not. It is allowed, but essentially never safe to communicate to the compiler the error types your function returns. Its ok to use custom error types; you just can't tell the compiler about them. This is a problem that is so deep in the language it has influenced how error types and functions are designed in the standard library. I'm aware of one production outage related to this problem (yeah yeah, blame bad reviews and bad testing, I get it, it still happened). I've caught it a dozen times in code reviews (especially after that happened).
Its a real problem. People don't write perfect code.
Yet another case of that old pitfall. Untyped nil uses the same keyword as typed nil, when they are not equal. It's an unfortunate contradiction within the language.
And it means that the entrenched practice of
err != /* untyped */ nil
is an overly specific check (nil error is harmless whether it contains the type information or not).Someone, somewhere has thrown at us a principle ("the theoretically correct decision") and someone else has thrown at us a conflicting principle ("err != nil is a universal idiom").
The problem: these are not principles! Both of these are decisions with tradeoffs. Who is making these decisions? Some blog authors or conference speakers? No: the team that owns the code. So, either:
- have a linter that catches *ErrX return declarations automatically,
- or, have a linter that prevents err != nil (generally: interface != nil)
Since the latter is impractical today, one needs to decide the former. Solved, isn't it?
It is safe as long as you don't write too much code for no reason. The problem in the example is that DatabaseError needlessly defined an Error method. It serves no purpose other than to allow the issue to arise. Remove said method, which has no reason to exist, and the program will fail to compile.
> Its a real problem. People don't write perfect code.
Okay, sure. It is possible that a programmer may, for whatever reason, leave out entire blocks of logic that the program needs. Imagine not adding billing logic to your storefront software – I'm sure it has happened to someone before! Making interfaces more intuitive does not solve that problem, though.
var errConcrete *strconv.NumError = nil
var err error = errConcrete
err != nil // true
Please fix this one.The problem was that I had a variable of an interface type and in the code was producing different objects implementing that interface. Now, a nil value was also allowed if none of the cases matched and then the variable was stored in a sync.Map as a value.
The problem was that when retriving the value the nil check never matched. Why? Well, it's type is defined as the interface even if it doesn't point to any implementation.
I get that the designers might not have considered a different option: it is allowed to invoke a method on a nil object, making what some languages would call static methods. Still, I agree with the grandparent: I think this is Go's biggest flaw, simply because it is so surprising.
func QueryDatabase(sql string) (string, *DatabaseError) {
Returning *DatabaseError instead of error is a pretty clear programmer mistake, which should be caught and fixed by any reasonable kind of code review.---
Long explanation:
In Go, "error" is an interface - i.e. a type that has an "Error() string" method defined on it.
The custom defined struct, "DatabaseError" is not an interface - it's a struct, and it has an "Error() string" defined on it. Therefore, any value of "DatabaseError" (or "*DatabaseError") type fulfills the "error" interface, and can be cast to a non-nil "error". Even the nil pointer to "DatabaseError" - you can call methods on a nil pointer, therefore it's a valid non-nil interface.
The problem in the code is the implicit cast of "*DatabaseError" struct into the "error" interface in line 21., which assumes that nil pointer is the same as nil interface. It isn't. The solution is either to 1) return "error" instead of "*DatabaseError" in "QueryDatabase" (https://go.dev/play/p/fb0e_4loDBf), or 2) to explicitly check the return value of "QueryDatabase" for nil pointer before cast (https://go.dev/play/p/TgikAk1mSn0).
I prefer the former approach, because even if you're free to write your own "error" interface implementation, you're still supposed to use it through the "error" interface, not the struct pointer directly. Interface is more than just a struct pointer.
P.S.
As I explained in the other comment, the intention of the original comment is to differentiate between kinds of errors through the type system. That's not the Go way - Go avoids type hierarchies as much as possible. The proper way to differentiate errors in Go is through values: https://go.dev/play/p/ddzhAqRgK_1
Or, if the user insists on having a error type hierarchy, define a custom "Is(target error) bool" method on each custom error type: https://go.dev/play/p/gZmYgOq6wSo
- a typed nil: "I know the type, but there's no value"
- an untyped nil: "I don't know the type and there's no value"
But when you compare these two, they are not equal. Sigh.
(Correcting my wording: "untyped" is not precise enough, because it's "I don't know the type exactly, but I know that it's one of the types that fit X interface". So maybe more like "nonconcrete nil".)
package main
func main() {
foo := nil
_ = foo
}
Raises a compilation error: ./main.go:4:9: use of untyped nil in assignment
It's just that zero value (nil) of interface is not the same as a zero value (nil) of a pointer.Casting a nil pointer to an interface does not create a nil interface, in the same way that casting a zero integer or an empty string to an interface{} (any) doesn't create a nil interface{} (any).
I code solo, so I'm not sure how others do it.
The alternative is to have twenty methods?
I build a data structure, modify its state in a dozen ways and then return it to the caller.
Reasons:
- It's simple and easy to understand. There's no hidden complexity, no magic, no gotchas. Nothing is going to surprise me about it.
- It's a very readable cadence. Do the thing, check the error, do the thing, check the error, do the thing, check the error. Once you get used to the error checks it's extremely readable.
- Despite all the extra characters the actual mental load of the error checks is tiny. Yes it's more typing, but the hard bit about code is thinking not typing, and it doesn't make more thinking.
- If I have to do something special with the error, it's easy. There's no incentive to handle this error just like the rest, or ignore it and let the exception handler catch it. If this error needs (for example) extra logging then I can just put it in there for this error check, no hassle.
- It's pretty much standard across all Go code. If I have to deal with someone else's code, I can expect the same cadence, the same simplicity, the same readable pattern of error checks. One of the great things about Go is that it is opinionated about stuff like this.
Many effectful actions e.g. reading from a file system can have a range of different errors each of which you want to handle differently e.g. out of disk space versus lack of permissions.
It's great that you're treating errors as values. But you need pattern matching and other techniques as they make your code more: (a) readable, (b) safer, (c) simpler and (d) less verbose.
The hilarious thing is that eventually Go is going to get these because there is nothing but upside. And then at that point you're going to wonder how you ever survived without it.
The go way to do this would be:
switch {
case errors.Is(err, outOfDiskSpaceError):
// handle out of disk space error
case errors.Is(err, lackOfPermissionsError):
// handle lack of permissions
...
default:
// do the equivalent of the `_` case in a scala match statement or `t` in a lisp cond
}
Which is roughly as readable as scala/rust's match statements. Moreso if someone tried to get cute in scala and bind both the object as a whole and parts of it at the same time ( something like `case x @ Type(_, _, y, _)` ) or when people get real cute with the unapply method.I mean, I like scala. It's fun. But I would never say it's more readable than go. I've been left to support things that happen when a team of average intelligence devs get a hold of it.
You're also really comparing an MIT and New Jersey style solution here, and judging them both on MIT merits. And I don't think that's exactly a fair argument.
PHP tried to correct the mistake of not giving some more thought to the switch statement at the beginning by including a new "match" expression in PHP 8 - fun times for everyone who used classes called "Match"...
I use a language that has both pattern matching and case constructs. I use both.
If you're doing complex pattern matching all over the place (especially matching the same sets of cases repeatedly in multiple locations in the code), maybe your design sucks. It's not making effective use of OOP or some other applicable organizational principle.
Yeah, but when you need to bash someone over the head, the rocket suddenly isn't any use anymore.
IOW, sometimes the simpler thing is better.
In fact, in practice, the simpler thing is usually better... Like, rocks are usually more useful to the average person than rockets are.
struct BinaryTree {
leaf_value: int,
left_child: BinaryTree,
right_child: BinaryTree
}
function sum_leaves(tree: BinaryTree) -> int {
if tree.left_child != nil {
return sum_leaves(tree.left_child) + sum_leaves(tree.right_child);
} else {
return tree.leaf_value;
}
}
vs: enum BinaryTree {
Leaf(int),
Branch(BinaryTree, BinaryTree)
}
function sum_leaves(tree: BinaryTree) -> int {
match tree {
Leaf(leaf) => leaf,
Branch(left, right) => sum_leaves(left) + sum_leaves(right)
}
}
(If you're thinking "the first example should be using inheritance + polymorphism", imagine that "BinaryTree" is in a different library than "sum_leaves". If you're now thinking "visitor pattern", sure, go write your hundreds of lines of boilerplate code if you like.)The first example is less safe because it's filled with invariants: left_child is nil iff right_child is nil, and leaf_value should only be accessed when they're nil. The second example has zero invariants. (You might think there would be an invariant that the children aren't nil, but languages with pattern matching tend to use Optional instead of nil, so that invariant isn't necessary.) If you make mistakes about when you access various fields in the first example, you'll be accessing leaf_value when it's uninitialized, or get a null dereference from one of the pointers.
As for readability, that's in the eye of the beholder, but I find the second example a lot more readable for the same reason: it's clear in both the data definition and the use site which fields exist.
All sorts of details vary across languages, even with a small example like this, but that's the basic differences.
In terms of errors, though, it's generally "the result is either a value and no error, or no value and one of these errors". I get how sum types would help with this, and I'm not arguing against that; they would be useful. But the pattern matching basically still has to deal with that outcome, and have a pattern for each error type. It doesn't strike me as being inherently safer, more readable, etc.
Not so! In Rust:
enum FileError {
FileNotFound,
FileExplodedWhileOpening,
}
impl Display for FileError { ... } // say how to print the errors
// Result is defined in the standard library.
// It's used to store results that may be successful, or may be an error:
enum Result<Success, Error> {
Ok(Success),
Err(Error),
}
fn read_file(path: Path) -> Result<File, FileError> {
...
}
fn main() {
match read_file("kittens.png") {
Ok(file) => // show kittens on screen
Err(err) => println!("Could not show kittens :-( \n{}", err),
}
}I agree that sum types are lovely and that pattern matching makes them nice to work with but I don’t think you really make the case well here that it’d be superior rather than just personal preference.
I'm not sure what you mean by "appropriately represented". What's an appropriate representation of "not present" if a leaf is allowed to be any int?
> Or even representing them with a function call isLeaf.
Let's see how it looks with an isLeaf() function:
struct BinaryTree {
leaf_value: int,
left_child: BinaryTree,
right_child: BinaryTree
}
function sum_leaves(tree: BinaryTree) -> int {
if tree.is_leaf() {
return tree.leaf_value;
else {
return sum_leaves(tree.left_child) + sum_leaves(tree.right_child);
}
}
It still has an "else" clause, and it's still full of invariants. Not sure how this is supposed to be much better?> I don’t think you really make the case well here that it’d be superior rather than just personal preference.
Increased type safety is not personal preference! Here's the list of errors that are easy to make in the first example, and literally impossible in the second:
- accessing `leaf_value` when it's not set
- accessing `left_child` when it's nil
- accessing `right_child` when it's nil
- setting exactly one of `left_child`, `right_child` to nil
- setting `leaf_value` when `left_child` or `right_child` is nil
(You might imagine that the "setting" mistakes are possible in the second example too. If Go merely gained pattern matching, this would be the case. Most languages that were born with pattern matching, though, don't have default/uninitialized values for everything, and so don't let you make those mistakes. I.e., you cannot construct a BinaryTree in Rust without choosing a value for the leaf or for the two branches when you do.)
Sure it is! People literally make this choice all the time. If leaf can be any int then I’d suggest a value that determines whether the value is a branch or a leaf. Which is drum roll how tagged unions work anyway.
You still don’t need the else clause with the early return.
Just to be clear the sum type/pattern matching is nice! But it’s perfectly acceptable to live without it and the world won’t end.
Much like other FP features, shoehorning pattern matching into a language doesn't give you nearly the same advantages as building a language around it, so I don't know that it would make Go significantly better.
The hard part is understanding the existing code. The more cluttered and verbose the code is, the harder that is. Go's boilerplate if err != nil return err, nil becomes something that your eyes just skim over - which is fine right up until you have some code that's doing something similar but not the same, and don't even notice.
I do have to spend a second reading the action if it's not just `return result, fmt.Errorf("failed to do the thing: %w", err)` but that's good, I think.
And all of this is way easier than trying to trace up through the stack to the nearest exception handler and work out what it will do with the error
edit: also, verbosity doesn't make code harder to understand, imho. If anything the other way around. Packing 5 statements into a single line is massively harder to read than separating those same 5 statements into 20 lines with error handlers.
You still have to do that part though? Like, this function returns err, so the caller returns err, so the caller of that returns err, ... - you've still got to walk up the stack to the point where the error is actually dealt with.
> edit: also, verbosity doesn't make code harder to understand, imho. If anything the other way around. Packing 5 statements into a single line is massively harder to read than separating those same 5 statements into 20 lines with error handlers.
Very much not my experience. There's a huge understandability hit when a function doesn't fit on a single screen and you have to scroll, so vertical space is really precious.
This is why we wrap errors. The error message gives a pretty good indication of what the stack was doing when it went wrong.
I had a junior dev work with me on some JS. I was writing it in functional style because it made sense at the time. He was really struggling, so I refactored it to old-school imperative and he understood it and was able to work with it. It might have been an issue with the way he was taught JS, but I think it's more that tightly-packed concise code is actually harder to parse. Not least because you have to understand the whole thing to work out wtf it's doing. Whereas with one-statement-per-line you can scan down to the lines you're interested in and focus on those.
OTOH, if you make sure you do something like
if err != nil {
return fmt.Errorf("what I was doing when the error happened: %v", err)
}
then you get very precise targeted errors appearing in logs that are much easier to track down and fix than either just returning the error or the usual generic catch block found in other languages.IME properly handled Go errors make for much more maintainable code.
IMO the real value in proper error handling (ie what makes fixing errors easy) is that context, not the precise line # of the error which, though useful, is supplementary data. Rust errors with anyhow::Contexts are far more workable than those without.
Edit: I actually wrote a simple Result handling package for Go that includes error context and stacktraces: https://github.com/kitd/chock
As I mention above, return instead the error with some identifying information and context, and you get highly targeted details needed to locate and fix the problem. And the way Go handles this makes it simpler than doing it via exception handling.
Golang has alot of good things about it. This is not one of them and is a wart on the language that is tolerated because the genesis of the language is to be an entirely inverted approach to verbosity than Java. Its not something to be praised.
It's easy to look at a 50-line function with one statement every 5 lines and find the bit I'm interested in, because it's easier to screen out the bits I'm not interested in. Rather than unentangling a 5-line function that has 10 statements in it, because I have to work out what all of it does in order to understand it and I can't focus in on the bit I'm interested in.
DRY isn’t about reducing LoC
But not copying and pasting code by merging common logic is a matter of reducing the number of times you can fuck up, and reducing the number of “accidentally/unnecessarily special-cased” scenarios. You’re trying to reduce the amount of information needed to understand the codebase. The former is preserving the information, it’s just writing it more densely.
There absolutely are gotchas, exactly because they are not sum types. There are functions where both “slots” are used as return types, when an error occurred.
> It's a very readable cadence. Do the thing, check the error, do the thing
Arguably, you can’t reasonably handle most errors in-place, you just don’t have enough context for that. Also, you want to make your business logic right — all those verbose, often incorrect/naive error handles will just make it harder to read your own logic. Also, very easy to accidentally swallow an error - exceptions/sum types are much better in this regard, you can’t not care about them.
One thing you can do is go to the Go slack space at https://invite.slack.golangbridge.org/ and ask for a review in the #reviews channel; 20 error checks is a lot.
You've just described Scala which inspired many of Rust's features.
Swift I suppose although it doesn't make much sense outside of the Apple ecosystem.
Sometimes you want to return a partial result along with an error. Go's idiom of returning multiple values, with the final value being an error, allows this situation to be easily supported.
Sometimes you do, and there are types that represents that. Having a single type that is almost always used a certain way but occasionally used subtly differently is a trap waiting to bite you.
Go has a convention of documenting each function with a comment (which is adhered to by the standard library, my own code, and any other code I'd consider worthy of depending on).
So when I think this subtlety matters, I check the documentation. Usually I don't care either way: When I get a non-nil error, I typically don't care about the other result (whether partial or the zero value).
The distinction rarely matters in practice (and when it does, the documentation is there). I think this simplicity is the right tradeoff (vs. being burdened with more types to think about).
So it's not that a type is more of a burden than documentation, but rather that a type is an additional burden (since we're taking on the documentation burden either way).
I like that Go's error handling is simple enough that I can keep all its rules in my head. And I like that the other parts of Go are simple like that too. It allows me to easily know exactly what is going on at the language level (while my attention is focused on higher levels).
If I get a result that came with a non-nil error, and I still plan to do something with the result, then it seems natural to think about the possibility that the result might be nil. I would consult the function's documentation, and maybe its source, and then maybe put in a nil check.
This is too rare a problem to be worth adding complexity to the language. There needs to be a big payoff for adding complexity. A language gets hard to use if it adds micro-complexities all over the place for the sake of preventing rare programming mistakes.
And if you wanted to do that in rust, that's a valid thing too, either as using a tuple instead of a sum type, or a tuple in the Err side of a result. You can describe what you're trying to do simply with the function signature.
Rust is actually largely error-unaware. It does have some syntactic sugar (mostly `?`, and even then that’s not restricted to errors), but for the most part Result just an enum with a `must_use` annotation, everything flows down from that.
There _is_ some futzing that sometimes have to happen due to various operations' Result types using incompatible Error types. That's just a thing that has to be dealt with.
Being able to define your own app-specific Error type which everything gets converted to is incredibly useful and powerful. Especially for web apps where you can return the correct response codes depending on the error.
It's also often taken care of by a sort thiserror macro invocation per "inner" type. There are obviously more complex error setups, but this covers the vast majority IME
I get why this is, but it does make me miss the Python Exception model of "there's ~15 base exception types. One of them is probably good enough for you". One could point out that the arguments are usually "just" strings there too, but at least there's some conventions.
I understand Rust's philosophy, I just find it annoying.
For bubbling up errors you have the ? operator, then on results you have map, map_err, and, ok_and...