| ▲ | Monads in C# (Part 2): Result(alexyorke.github.io) |
| 56 points by polygot 5 days ago | 70 comments |
| |
|
| ▲ | Roonerelli a day ago | parent | next [-] |
| I've had the misfortune of working on a C# code base that uses this pattern for many years. I've also used it with F#, where it feels natural - because the language supports discriminated unions and has operators for binding, mapping etc.
Without that, it feels like swimming against the tide. Code has a greater cognitive overhead when reading it for the first time.
And there is always a big over head for new starters needing to understand the code. It feels idiomatic in F#. It feels crow-barred in with C# |
| |
| ▲ | DimmieMan a day ago | parent | next [-] | | F# also has substantially better type inference so you don't need to write the types out everywhere, type aliases are first class too so you can easily write out some helper types for readability. You can pipe a monadic type through various functions writing little to no type declarations, doing it nicely is F#'s bread and butter. In C# version n+1 when the language is supposedly getting discriminated unions for real this time I still don't see them being used for monadic patterns like F# because they're going to remain a menace to compose. | |
| ▲ | douglasisshiny a day ago | parent | prev | next [-] | | I felt the same way with fp-ts then effect in typescript. Pretty cool libraries and I learned a lot about FP while trying them out for a couple of years, but a lot of ceremony and noise due to them (especially effect) almost being a new language on top of typescript. Recently got the opportunity to try out elixir at my job and I'm liking it thus far, although it is an adjustment. That static typing and type inference are being added to the language right now is helpful. | | |
| ▲ | gr4vityWall a day ago | parent [-] | | I had a similar impression with using those constructs on TypeScript. IMO it's hard to justify creating Option<T>/Result<T,E> wrappers when T|null and T|E will work well enough for the majority of use cases. effect specifically feels like a different programming language altogether. And maybe going that path and compiling down to TS/JS could've been a better path for them. I'm not on the ecosystem though, so it's an uninformed thought. |
| |
| ▲ | bonesss a day ago | parent | prev [-] | | Monadic binding and other functional mainstays in C# mostly fall into the same uncanny valley. Like non-exhaustive pattern matching, we get some nice sugar, but it’s not the same, and not a proper substitute for what we’re trying to do. F# ~~ripped off~~ is deeply inspired by OCaml, with a very practical impact on its standard library: there are facilities available for all the functional programming jazz one hasn’t though about or bumped into. In active patterns, pattern matching, recursive list comprehensions, applicatives, or computation expressions when you bump into the corners of the language you find a deep, mature, OCaml core that nerds much smarter and more talented have refined for decades. The language was built around those facilities. Bumping into the edges of the partial features in C# is a pretty common experience for me, resulting in choices about kludges to support a superficial syntax, raising concerns about goldbricking. It feels crowbarred because it was. “Railway oriented programming” passes over well as a concept, but it’s an easier sale when you see its use resulting in smaller, simpler, easier functions |
|
|
| ▲ | pimbrouwers 5 days ago | parent | prev | next [-] |
| I've been playing around with some of the "standard/common monads in C# for a while now, in OSS (https://github.com/pimbrouwers/Danom) and at work. It's awesome. I can't imagine working without them anymore. |
| |
| ▲ | oaiey 2 days ago | parent | next [-] | | Do you have a comparison to other libraries like https://github.com/louthy/language-ext Yours looks a lot more idiomatic to C# (hence acceptable for a mixed code base) but the above linked more "systematic". Not that I have used any or have any competence. | | |
| ▲ | pimbrouwers a day ago | parent [-] | | I normally do a much deeper dive into the existing ecosystem before I write a library like this. In this case, I tried a few of the popular packages, but in the end decided my own pursuit of the exact types I wanted was more interesting. So, I just went for it. |
| |
| ▲ | polygot 4 days ago | parent | prev [-] | | Thanks for sharing! Appreciate the OSS work. Is it ok if I reference that repo in my article? | | |
|
|
| ▲ | xnorswap 2 days ago | parent | prev | next [-] |
| I really dislike this pattern: try
{
id = int.Parse(inputId);
}
catch (Exception ex) when (ex is FormatException or OverflowException)
{
throw new InvalidOperationException("DeactivateUser failed at: parse id", ex);
}
Where all you're doing when you catch an exception is throwing it in a more generic way. You could just let the FormatException or OverflowException bubble up, so the parent can handle those differently if needed. If you want to hide that implementation detail, then you should still consider throwing more specific types of exception for different errors. Just throwing InvalidOperationException for everything feels lazy.You've gone out your way to destroy the information being passed up when using exceptions, to demonstrate the value in having error types. It would be far more conventional to also provide a `TryDeactivateUser` function that cannot throw an exception. The article does note this, but hand-waves it away. I'm not against Result types, but I don't find this article entirely convincing. |
| |
| ▲ | fuzzy2 a day ago | parent | next [-] | | Doing it like this (or preferably with a custom exception) translates the technical problem into a domain problem. Without doing this, callers can't properly handle it. FormatException or OverflowException could be thrown at multiple locations, not just in parsing the user ID. This here is an InvalidUserIdException. It could be derived from ArgumentException, but IMHO InvalidOperationException is not appropriate. | | |
| ▲ | reactordev a day ago | parent | next [-] | | Translating into a custom exception is the way to go here. Bubbling up exceptions from your abstractions is fine for development but not a good experience for users of your API. I would rather see custom exceptions thrown than rethrowing. | |
| ▲ | xnorswap a day ago | parent | prev [-] | | You're right that domain specific exceptions would be much better. As an aside, generating domain specific exceptions is precisely the kind of busywork that traditionally it is hard to find motivation to do but that LLMs excel at. | | |
| ▲ | bonesss a day ago | parent | next [-] | | Re: Domain Specific Exceptions Code snippets in IDEs like Visual Studio and refactoring tools like Resharper offer shortcut accessible auto generation of those kinds of entities with deterministic results. They also have routines to extract and move entities to their own files afterwards in a keyboard based workflow. They are far less work than a prompt, faster, can be shared in the project or team, and are guarantee-able to confirm to coding standards, logging specifics and desired inheritance chain for your root domain exception. It works on-prem, offline, for zero tokens. | |
| ▲ | fuzzy2 a day ago | parent | prev [-] | | I mean, at least on modern C#, it could be as simple as public class UserIdInvalidException(Exception innerException) : Exception("Invalid User ID", innerException);
Even easier than most data objects you’d have to define anyway. And then, Exceptions are part of the contract. I’d rather not have an LLM write that up, but that’s just personal preference. |
|
| |
| ▲ | magicalhippo 2 days ago | parent | prev | next [-] | | The original exception is available[1] in the InnerException though, so upstream can handle those differently. [1]: https://learn.microsoft.com/en-us/dotnet/api/system.invalido... | | |
| ▲ | xnorswap 2 days ago | parent | next [-] | | Ok, "destroy" was too strong, "hide" would have been a better choice of words. You've still made it harder to handle. | |
| ▲ | moogly a day ago | parent | prev [-] | | That works somewhat for 2 levels of exceptions. When you start having dig into several levels of inner exceptions, gods help you. |
| |
| ▲ | bob1029 2 days ago | parent | prev | next [-] | | This code path leaves entire classes of unhandled parse exceptions on the table. I have a hard time believing this could be intentional. The safest and most concise approach is to use int.TryParse on the inputId and throw if it returns false. | | |
| ▲ | xnorswap 2 days ago | parent [-] | | Does it? Int.Parse says it can only return those 2 exceptions or ArgumentNullException, but nulls have been handled already. https://learn.microsoft.com/en-us/dotnet/api/system.int32.pa... | | |
| ▲ | bob1029 a day ago | parent [-] | | Fine but with all that code we are implying there is some case we don't want to catch for some reason. If any effective parse error should always throw we should simply do that instead of playing games. | | |
| ▲ | bonesss a day ago | parent [-] | | You’re not accounting for what the example actually does. The example provides exception conformance at the API level and specific logging information for tracing and debugging. It’s not playing games, it’s simplifying details for upstream consumers and explaining their contribution to the issue, while being explicit in its intentioned failure modes and failure behaviour. This code cannot tell callers why the callers have sent it garbage or what to do about that, it is failing and explaining why. Throwing “invalid : bad user id” is substantively different than rethrowing “index out of bounds : no string character at index -1”. The wrapped exception has all the original detail, its just unified and more descriptive. |
|
|
| |
| ▲ | torginus a day ago | parent | prev | next [-] | | That's not how you're supposed to handle this kind of errors (according to .NET designers) - there's 2 kinds of errors in concept, everyday errors that are to be expected, such as a dictionary not containing a key, or in this case, a user supplying a badly formatted integer. For this you have the Try.. methods with TryGetValue, TryParse etc. Go for example, allows for multiple return values, so it allows more elegant handling of this exact cass. Then there's the serious kind of error, when something you didn't expect goes wrong. That's what exceptions are for. If this distinction is followed, then you don't want to handle specific exceptions (with very few notable distinctions, like TaskCanceledException), you just either pick a recoverable function scope (like a HTTP handler), and let the exception bubble to its top, at which point you report an error to the user, and log what happened. If such a thing is not possible, just let the program crash. | |
| ▲ | 2 days ago | parent | prev | next [-] | | [deleted] | |
| ▲ | naasking a day ago | parent | prev [-] | | You're leaking implementation details if you let exceptions bubble. Sometimes this is ok if all of the callers are aware of the implementation details anyway, but it can make refactoring or changing implementations more difficult otherwise. | | |
| ▲ | wvenable 16 hours ago | parent [-] | | You should leak implementation details on exceptions -- if an operation fails because of a network timeout or file access issue, that's useful information. Most exceptions cannot be meaningfully caught anyway so let me log with a good stack trace and be done with it. | | |
| ▲ | naasking 8 hours ago | parent [-] | | The inner, wrapped exception is logged. Leaking exception details can also leak privileged information, and if you're not careful this can leak information to attackers too. More information is not necessarily better. | | |
| ▲ | wvenable 5 hours ago | parent [-] | | If your error logging is leaking privileged information to attackers that's a completely different problem from what you should do in code when throwing exceptions. Wrapping exceptions to remove information is mostly a pointless exercise. You should be doing it only to add additional context. |
|
|
|
|
|
| ▲ | 89netraM 2 days ago | parent | prev | next [-] |
| A friend and I wrote our master thesis on how to ergonomically fit monads into imperative programming languages [^1], taking inspiration from Haskells do-notation to get away from the method chaining. It's more on the theoretical side, but we did write a small implementation in C# [^2] (we use the exclamation mark as bind). We really could have used som better types though, this post seems have found a better direction. [^1]: https://odr.chalmers.se/items/91bf8c4b-93dd-43ca-8ac2-8b0d2c... [^2]: https://github.com/master-of-monads/monads-cs/blob/89netram/... |
|
| ▲ | mikemarsh a day ago | parent | prev | next [-] |
| I'm glad Paul Louth of https://github.com/louthy/language-ext/ is here in the comments. At this point basically everyone has been exposed to the concept of `Option/Result/Either/etc.`, and discussions typically end up revolving around the aesthetics of exception throwing vs. method chaining vs. if statements etc. without any concept of the bigger picture. LanguageExt really presents a unified vision for and experience of Functional Programming in C# for those are who truly interested, akin to what's been going on in the Scala ecosystem for years. I've been using it and following its development for a few years now and it continually impresses me and makes C# fresh and exciting each day. |
| |
| ▲ | louthy a day ago | parent [-] | | Aww, thanks Mike! And thank you for the contributions and suggestions too :) > At this point basically everyone has been exposed to the concept of `Option/Result/Either/etc. and discussions typically end up revolving around the aesthetics of exception throwing vs. method chaining vs. if statements etc. without any concept of the bigger picture. I think this is a really important point. 12 years ago I created a project called 'csharp-monad' [1], it was the forerunner to language-ext [2], which I still keep on github for posterity. It has the following monadic types: Either<L, R>
IO<T>
Option<T>
Parser<T>
Reader<E,T>
RWS<R,W,S,T>
State<S,T>
Try<T>
Writer<W,T>
One thing I realised after developing these monadic types was that they're not much use on their own. If your List<T> type's Find method doesn't return Option<T>, then you haven't gained anything.I see others on here are taking a similar journey to the one I took over a decade ago. There's an obsession over creating Result types (Either<L, R> and Fin<A> in language-ext, btw) and the other basic monads, but there's no thought as to what comes next. Everyone of them will realise that their result-type is useless if nothing returns it. If you're serious about creating declarative code, then you need an ecosystem that is declarative. And that's why I decided that a project called "csharp-monad" was too limiting, so I started again (language-ext) and I started writing immutable collections, concurrency primitives, parsers, and effect systems (amongst others). Where everything works with everything else. A fully integrated functional ecosytem. The idea is to make something that initially augments the BCL and then replaces/wraps it out of existence. I want to build a complete C# functional framework ecosystem (which admittedly is quite an undertaking for one person). I'm sometimes a little wary about going all in on the evangelism here. C# devs in general tend to 'stick to what they know' and don't always like the new, especially when it's not idiomatic - you can see it in a number of the sub-threads here. But I made a decision early on to fuck the norms and focus on making something good on its own terms. And for those that wonder "Why C#?" or "Why not F#?", well C# has one of the best compilers and tooling ecosystems out there, it's got an amazing set of functional language features, it will have ADTs in the next version, and it has a strong library ecosystem. It also has the same kind of borrow checker low level capability as Rust [3]. So as an all-rounder language it's quite hard to beat: from 'to the metal bit-wrangling', right the way up to monad comprehensions. It should be taken more seriously as a functional language, but just generally as a language that can survive the turmoil of a long-lived project (where mostly you want easy to maintain code for the long-term, but occasionally you might need to go in and optimise the hell out of something). My approach will piss some people off, but my aim is for it to be like the Cats or Scalaz community within the larger Scala community. It's certainly a labour of love right now. But, over a decade later I'm still enjoying it, so it can't be all bad. (PS Mike, I have new highly optimised Foldable functionality coming that is faster than a regular C# for-loop over an array. Watch this space!) [1] https://github.com/louthy/csharp-monad [2] https://github.com/louthy/language-ext [3] https://em-tg.github.io/csborrow/ |
|
|
| ▲ | louthy a day ago | parent | prev | next [-] |
| This is all very basic, instead you can use C#'s new static interface methods feature to create higher-kinded traits where you can properly generalise over a monad trait (or applicatives, functors, foldables, etc.), which is what I do in language-ext [0]. I'm not saying that implementing SelectMany for specific data-types isn't valuable. It certainly ends up with more elegant and maintainable code, but the true power of monads and other pure-FP patterns opens up when you can fully generalise. * I have a blog series on it that covers implementing Semigroups, Monoids, Functors, Foldables, Traversables, Applicatives, Monads, and Monad Transformers (in C#) [1] * The monad episode (my 'Yet Another Monad Tutorial') [2] * An entire app generalised over any monad where the monad must support specific traits [3]. It's the program I use to send out the newsletters from my blog. Happy to answer any questions on it. [0] https://github.com/louthy/language-ext/ [1] https://paullouth.com/higher-kinds-in-c-with-language-ext/ [2] https://paullouth.com/higher-kinds-in-csharp-with-language-e... [3] https://github.com/louthy/language-ext/tree/main/Samples/New... |
| |
| ▲ | FrustratedMonky a day ago | parent [-] | | Serious question, at this point, have all F# features been fully incorporated into C#? | | |
| ▲ | louthy a day ago | parent [-] | | Not discriminated unions, but they're coming (I think next version of C#). Although for now you can simulate them quite easily: public abstract record Either<L, R>;
public sealed record Left<L, R>(L Value) : Either<L, R>;
public sealed record Right<L, R>(R Value) : Either<L, R>;
Pattern-matching works well with these simulated algebraic data-types. Obviously, exhaustiveness checks can't work on 'open' types, so it's not perfect, but you can unpack values, apply predicate clauses, etc.Other more niche features like type-providers don't exist either (although arguably those could be done with source-generators in C#). It's been a long time since I did any F#, so not sure if there's anything new in there I'm unaware of. | | |
| ▲ | debugnik a day ago | parent [-] | | That will allocate for any constructed Either though. F#'s Result and ValueOption are value-types (structs), and value-type variants recently added support for sharing fields between variants when the name and type match. | | |
| ▲ | louthy a day ago | parent [-] | | Yes, that's the limitation until the value-type DUs arrive in C# 15. In previous versions of language-ext, I defined Either as a struct with bespoke Match methods to pattern-match. But once pattern-matching appeared in C# proper, it didn't make sense to keep the struct type. |
|
|
|
|
|
| ▲ | torginus a day ago | parent | prev | next [-] |
| It has been a long-standing trend/belief/whatever that FP is just somehow better, it's kind of have been this belief that has endured for decades. Part of that belief is that exceptions are bad and option/result types are the way to go for proper error handling. I don't think this is true at all, they are just different, with procedural programming being control-flow oriented and fp being dataflow oriented. Monads are just dataflow oriented error handling, which is comes with its own set of tradeoffs and advantages, the key disadvantages being the necessity of an advanced type inference-system, to allow natural looking usage, and the function signatures having to support the notion that this function can indeed throw an error. Implementation wise, the generated assembly is not more efficient, as the error passing plumbing needs to appear at every functions return site, even if no error happens. I'm not saying Monads as error handling are an inherently bad concept, but neither are exceptions (as many usually suggest), and using both depend heavily on language support to make them ergonomic, which in the case of C# and monads, is missing. |
| |
| ▲ | louthy a day ago | parent [-] | | On your definition of FP you're right. But pure functional programming has the following over regular imperative coding: * Fewer bugs: Pure functions, which have no side effects and depend only on their input parameters, are easier to reason about and test, leading to fewer bugs in the code-base. * Easier optimisation: Since pure functions do not have any side effects, they can be more easily optimised by the compiler or runtime system. This can lead to improved performance. * Faster feature addition: The lack of side effects and mutable state in pure functional programming makes it easier to add new features without introducing unintended consequences. This can lead to faster development cycles. * Improved code clarity: Pure functions are self-contained and independent, making the code more modular and easier to understand. This can improve code maintainability. * Parallelisation: Pure functions can be easily parallelised, as they do not depend on shared mutable state, which can lead to improved scalability. * Composition: This is the big one. Only pure functional programming has truly effective composition. Composition with impure components sums the impurities into a sea of undeclared complexity that is hard for the human brain to reason about. Whereas composing pure functions leads to new pure functions – it's pure all the way down, it's turtles all the way down. I find it so much easier to write code when I don't have to worry about what's going on inside every function I use. That's obviously way beyond just having a declarative return type. And in languages like C# you have to be extremely self-disciplined to 'do the right thing'. But what I've found (after being a procedural dev for ~15 years, then a OO dev for ~15 years, and now an FP dev for about 12 years) is that pure functional programming is just easier on my brain. It makes sense in the way that a mathematical proof makes sense. YMMV of course, but for me it was a revelation. |
|
|
| ▲ | epolanski 2 days ago | parent | prev | next [-] |
| Small OT but part of me dies when data types that respect some laws are just labeled monads. Nobody calls an array a monad, even though an array admits a monad instance. Option, Result, Array, Either, FunkyFoo, whatever you want are just data types. They only become monads when combined with some functions (map, bind, apply, flatmap), and that combination of things respects a set of law. But calling a data type alone a monad has done nothing but overcomplicate the whole matter for decades. |
| |
| ▲ | mrkeen a day ago | parent | next [-] | | Sure, but this article's Result implements Ok(), Map(), and Bind() | |
| ▲ | pjc50 a day ago | parent | prev [-] | | I was wondering about that. "Monad" is a mildly obfuscatory term for "function that takes one argument and returns one value of the same type", and a List is not a function. | | |
| ▲ | mrkeen a day ago | parent | next [-] | | Where did you get that definition? "function that takes one argument and returns one value of the same type" is the identity function. | | |
| ▲ | epolanski a day ago | parent [-] | | Identity function returns the same _value_. If it's only the same _type_, but the value is not the same, then it's an endomorphism. The function definitions look the same `a -> a`. string reversal, integer negation or toUpperCase are classical examples of endomorphisms. Identity is a specific case of endomorphism. | | |
| ▲ | mrkeen a day ago | parent [-] | | string reversal, integer negation or toUpperCase are classical examples of functions which will not compile as `a -> a` The function which will compile as `a -> a` is the identity function. | | |
| ▲ | epolanski a day ago | parent [-] | | That's correct, but I'm not sure how it relates to my comment as I said that `a -> a` is just an endomorphism. identity, uppercase or negate are all endomorphisms, with identity being the only generic one. |
|
|
| |
| ▲ | louthy a day ago | parent | prev [-] | | I think you're describing part of the bind function, which is part of the definition of the monad interface: a -> m b
But the full definition is: bind :: (a -> m b) -> m a -> m b
In C#, it would look like this: M<B> Bind(Func<A, M<B>> f, M<A> ma)
Assuming a future version of C# that supports higher-kinds that is.In my language-ext library, I achieve a higher-kinded monad trait [1], like so: public interface Monad<M> : Applicative<M>,
where M : Monad<M>
{
static abstract K<M, B> Bind<A, B>(K<M, A> ma, Func<A, K<M, B>> f);
}
Which is what the original comment is about. Most people in C# are not creating monads when they implement Bind or SelectMany for their type. They are simply making 'do-notation' work (LINQ). The monad abstraction isn't there until you define the Monad trait that allows the writing of any function constrained to said trait.For example, a `When` function that runs the `then` monad when the `predicate` monad returns `true` for its bound value: public static K<M, Unit> When<M>(K<M, bool> predicate, K<M, Unit> then)
where M : Monad<M> =>
predicate.Bind(flag => flag ? then : M.Pure(unit));
This will work for any monad, `Option`, `List`, `Reader`, ... or anything that defines the trait.So types like `Option` are just pure data-types. They become monads when the Monad trait is implemented for them. The monad trait can be implemented for data-types and function-types. Reader, for example, has the Monad trait implemented for the function: Func<E, A> btw, monads also inherit behaviour from applicative and functor. The ability to lift pure values into the monad (a -> m a) is vital to making monads useful. This is `select` in LINQ, `pure` in Haskell's Applicative, and `Pure` in language-ext's Applicative [2]. [1] https://github.com/louthy/language-ext/blob/main/LanguageExt... [2] https://github.com/louthy/language-ext/blob/main/LanguageExt... |
|
|
|
| ▲ | jerf a day ago | parent | prev | next [-] |
| Result<User, Error> result =
ParseId(inputId)
.Bind(FindUser)
.Bind(DeactivateDecision);
This does not implement monads as Haskell has them. In particular, Haskell can do: do
id <- ParseID inputId
user <- FindUser id
posts <- FindPostsByUserId id
deactivateDecision user posts
Note id getting used multiple times. "Monad" is not a pipeline where each value can be used only once. In fact if anything quite the opposite, their power comes from being able to use things more than once. If you desugar the do syntax, you end up with a deeply nested function call, which is necessary to make the monad interface work. It can not be achieved with method chaining because it fails to have the nested function calls. Any putative "monad" implementation based on method chaining is wrong, barring some future language that I've not seen that is powerful enough to somehow turn those into nested closures rather than the obvious function calls.I wrote what you might call an acid test for monad implementations a while back: https://jerf.org/iri/post/2928/ It's phrased in terms of tutorials but it works for implementations as well; you should be able to transliterate the example into your monad implementation, and it ought to look at least halfway decent if it's going to be usable. I won't say that necessarily has every last nuance (looking back at it, maybe I need to add something for short-circuiting the rest of a computation), but it seems to catch most things. (Observe the date; this is not targeted at the original poster or anything.) (The idea of something that can be used "exactly once" is of interest in its own right; google up "linear types" if you are interested in that. But that's unrelated to the monad interface.) |
| |
| ▲ | louthy a day ago | parent [-] | | In C# you can implement SelectMany for a type and that gives this: from id in ParseId(inputId)
from user in FindUser(id)
from posts in FindPostsByUserId(id)
from res in DeactivateDecision(user, posts)
select res;
It is the equivalent to do-notation (was directly inspired by it). Here's an example from the language-ext Samples [1], it's a game of 21/pontoon.> I wrote what you might call an acid test for monad implementations a while back: https://jerf.org/iri/post/2928/ It's phrased in terms of tutorials but it works for implementations as well; you should be able to transliterate the example into your monad implementation, and it ought to look at least halfway decent if it's going to be usable. If I try to implement the test from your blog with Seq type in language-ext (using C#), then I get: Seq<(int, string)> minimal(bool b) =>
from x in b ? Seq(1, 2) : Seq(3, 4)
from r in x % 2 == 0
? from y in Seq("a", "b")
select (x, y)
: from y in Seq("y", "z")
select (x, y)
select r;
It yields: [(1, y), (1, z), (2, a), (2, b)]
[(3, y), (3, z), (4, a), (4, b)]
Which I think passes your test.[1] https://github.com/louthy/language-ext/blob/main/Samples/Car... | | |
| ▲ | jerf a day ago | parent [-] | | It looks like it. My claim was not (and is not) that C# can't implement it, but that what is discussed in the post does not. | | |
| ▲ | louthy a day ago | parent [-] | | Fair enough :) By the way, I happen to agree on the general point, in my blog teaching Monads in C# [1], I wrote this: "I often see other language ecosystems trying to bring monads into their domain. But, without first-class support for monads (like do notation in Haskell or LINQ in C#), they are (in my humble opinion) too hard to use. LINQ is the killer feature that allows C# to be one of very few languages that can facilitate genuine pure functional programming." So, yeah, regular fluent method chaining isn't really enough to make monads useful. [1] https://paullouth.com/higher-kinds-in-csharp-with-language-e... |
|
|
|
|
| ▲ | galkk 2 days ago | parent | prev | next [-] |
| Idk, to me that constant Result<User, Error>
looks extremely ugly and unergonomic, even just to type.I understand that this is complicated topic and there were a lot of strong opinions even inside of Google about it, but god, I miss absl::StatusOr and ASSIGN_OR_RETURN. Yes, it won’t work without preprocessor magic (and that’s why this article goes through heavy functional stuff, otherwise it just cannot work in language like C#), but it’s so easy and natural to use in base case, it feels like cheating. |
| |
| ▲ | simonask 2 days ago | parent | next [-] | | I think in C# the way to solve this is to have two separate types, `Ok<TValue>` and `Err<TError>`, and provide implicit conversions for both to `Result<TValue, TError>`. The static method approach showcased in the article is really long-winded. | | |
| ▲ | oaiey 2 days ago | parent [-] | | Yes. Or even simpler, static imported functions. That is similar to how ASP.NET Core handles HTTP feedback. A very common and understood concept. With implicit type parameters this boils down to Ok(4) or BadRequest() |
| |
| ▲ | a day ago | parent | prev | next [-] | | [deleted] | |
| ▲ | moogly a day ago | parent | prev | next [-] | | Is this similar? https://github.com/amantinband/error-or What's some of the "preprocessor magic" that makes this[1] more ergonomic to use? [1]: https://github.com/abseil/abseil-cpp/blob/master/absl/status... | | |
| ▲ | galkk 18 hours ago | parent [-] | | Look at the long chains of methods in https://github.com/amantinband/error-or?tab=readme-ov-file#n.... The code looks quite unnatural. In google/c++ you can do much simpler, but with preprocessor magic. Example: absl::StatusOr<User> loadUserById(int userId) { ... }
absl::Status populateItems(User user, std::vector<Item>& items) {...}
absl::StatusOr<Item> findItems(int userId) {
ASSIGN_OR_RETURN(auto user, loadUserById(userId));
std::vector<Item> items;
RETURN_IF_ERROR(populateItems(user, items));
for (auto& item: items) {
...
}
}
ASSIGN_OR_RETURN and RETURN_IF_ERROR essentially preprocessor macroses, that expand, more or less into. absl::StatusOr<Item> findItems(int userId) {
auto userOrStatus = loadUserById(userId);
if (!userOrStatus.ok()) return userOrStatus.status();
auto user = *userOrStatus;
std::vector<Item> items;
absl::Status st2 = populateItems(user, items));
if (!st2.ok()) return st2;
}
No long and ugly method invocation chains, no weirdly looking code - everything just works. You can see real life example here: https://github.com/protocolbuffers/protobuf/blob/bd7fe97e8c1...Again, even inside Google there were docs that considered those macroses bad and suggested to write straightforward code, but I'm in the camp who considers them useful and, maybe, sole good use of preprocessor macros that I ever seen, as there are no other way to clearly and concisely express that in majority of languages. F# has something like that with Computational Expressions, but they are still limited. |
| |
| ▲ | pyrale 2 days ago | parent | prev [-] | | > I miss absl::StatusOr Sounds like you would rather have an `ErrorOr<User>` than a `Result<User, Error>`. Both are union types wrapped in a monadic construct. | | |
| ▲ | galkk 18 hours ago | parent [-] | | I wrote example above: https://news.ycombinator.com/item?id=46508392 My point is not the types/monadic constructs, etc (I love to do functional jerk off as a guy next to me, though), but that there are ways to keep code readable and straightforward without neither invocation chains DoOne().OnError().ThenDoTwo().ThenDoThree().OnError() nor coloring/await mess, nor golang-style useless error handling noise |
|
|
|
| ▲ | seblon a day ago | parent | prev | next [-] |
| Last Year, i wrote some Monad Mini Framework for my own, but focusing only on the Result-Type itself. I planned to publish it, but i think today is a good day. Here we go: https://codeberg.org/Arakis/Result |
|
| ▲ | vips7L a day ago | parent | prev | next [-] |
| Looking forward to seeing what error handling starts to look like in C# once they have unions. I really like domain errors being checked by the type system. |
|
| ▲ | polygot a day ago | parent | prev | next [-] |
| Hi, author here. Thanks for the feedback! I'll take a look at the article tonight and go through the comments and update the post based on the comments. |
|
| ▲ | naasking a day ago | parent | prev | next [-] |
| Good review, but I frankly don't see the point of Result<T0, T1>. Just have the error be an exception type as exceptions idiomatically represents errors in .NET. Then you're down to only 1 type argument which is much less noisy. That's what I've used for the result type in my library that I've been using for years. I don't use it often, but it's very handy when appropriate. |
| |
| ▲ | louthy a day ago | parent [-] | | If you ask yourself what the meaning of the word 'exception' is and then consider how many failures are exceptional, then one quickly realises that exceptions are the worst thing you could use to represent expected failure conditions. The only time we should throw (or even pass around) exceptions is if there isn't a slot in the co-domain to inject a value in to. | | |
| ▲ | veleon a day ago | parent | next [-] | | I think OP means using Result<T> where Right in the Either is always implied to be Exception, much like Fin<A> in language-ext [0] [0] https://louthy.github.io/language-ext/LanguageExt.Core/Monad... | | |
| ▲ | louthy a day ago | parent [-] | | I think you mean Left. And Left in Fin is Error, which has two subtypes: Expected and Exceptional, along with bespoke matching primitives for exceptional and non-exceptional errors (for the reasons I outlined) I understood what they meant, Exception is still a poor type for declarative error handling, because it’s unclear whether an event is truly exceptional. |
| |
| ▲ | naasking a day ago | parent | prev [-] | | The dangers and pitfalls of exceptions are completely irrelevant if all you're doing is using an exception as a value and not for control flow. | | |
| ▲ | louthy a day ago | parent [-] | | It’s not about danger it’s about being declarative. That’s kinda the point of using these ‘result’ types: you’re fully declaring the codomain of the function — barring exceptions — and so if your codomain is augmented with Exception then it’s pretty hard to know whether all exceptions will be returned in value form, or just exceptional exceptions! It’s fails the declarative test. | | |
| ▲ | naasking 8 hours ago | parent [-] | | It's not hard at all: a return type of Result is when it's returned in value form. |
|
|
|
|
|
| ▲ | neonsunset a day ago | parent | prev [-] |
| [dead] |