C# and F# approaches to illegal states
enterprisecraftsmanship.com
enterprisecraftsmanship.com
Yes it is a contrived example, yes you can write "better" C#, but this is a short example for illustrative purposes.
I bet if somebody wrote a blog post about Djikstra's letter "GOTO considered harmful" 45+ years ago, attempting to illustrate it with more examples, it would have collected similar comments: nothing wrong with GOTO, I can use it correctly; that example of spaghetti GOTO code is poor and not idiomatic; the example code is too simple and nobody would write it that way; yes it takes discipline to use GOTO without ill effects but nobody would accidentally/maliciously subvert my GOTO hierarchy; etc. Meanwhile, the original point about structuring code using different techniques to help avoid problems (and avoid GOTO) would be lost.
It's like people are overly defensive about language features (or lack thereof) as if writing extra boilerplate and not letting the compiler help is a badge of honor.
EDIT: clarity
class Range {
public int From {get;}
public int To {get;}
public Range(int from, int to) {
Assert(from <= to);
From = from; To = to;
}
}Your proposed problem might be better solved with the Option type and a simple guard to return None if out of range.
>comically easy to avoid using a standard OO approach
This is an ironic statement given that your code, as written, is circumvented by subclassing Range and side-stepping its constructor. I suppose you actually meant "sealed class"... but we're getting off in the weeds again, missing the fundamental point of the post.
You are the second one who says this. I must admit that I do not understand how this would be possible. The properties are not virtual, and not writable for anything but the base constructor. To my knowledge, it's impossible avoid the base classes constructor by subclassing. I'm pretty sure you can actually circumvent the assert, but you would have to use the heavy weapons in the reflection API.
class Range
{
public int From { get; }
public int To { get; }
public Range(int from, int to)
{
Assert(from <= to);
From = from; To = to;
}
}
class LolRange : Range
{
public LolRange() : base(0,1)
{
From = 1; To = 0;
}
}
Error CS0200 Property or indexer 'Program.Range.From' cannot be assigned to -- it is read only
Error CS0200 Property or indexer 'Program.Range.To' cannot be assigned to -- it is read only abstract class User {
public string Name { get; }
abstract bool IsActive { get; }
public User(string name) {
this.Name = name;
}
}
class ActiveUser : User {
public ActiveUser(string name) : base(name) { }
private ICollection<Subscription> _subscriptions = new List<Subscription>()
public IEnumerable<Subscription> Subscriptions => _subscriptions;
IsActive => true;
public DeletedUser Delete() => new DeletedUser(Name, DateTime.Now);
public void AddSubscription(Subscription s) => Subscriptions.Add(subscription);
}
class DeletedUser : User {
public DeletedUser(string name) : base(name) { }
public DateTime DeletionDate {get;}
IsActive => false;
public DeletedUser(string name, DateTime deletionDate) : base(name) {
this.DeletionDate = deletionDate;
}
public ActiveUser Reactivate() => new ActiveUser(Name);
}The idea is that in more complex scenarios the best practice is to prefer composition over inheritance because modelling every single piece of state with subclasses would be sloppy and unmaintainable.
That is where design-by-contract (or simply put guard clauses) usually come into play.
type ActiveUser = { Name: string; Subscriptions: Subscription list }
type DeletedUser = { Name: string; DeletedDate: DateTime }
let delete (activeUser:ActiveUser) =
{ Name = activeUser.Name; DeletedDate = DateTime.Now }
let undelete (deletedUser:DeletedUser) =
{ Name = deletedUser.Name; Subscriptions = [] }
let addSubscription subscription activeUser =
{ activeUser with Subscriptions = subscription::activeUser.Subscriptions}
Your approach with subtyping is a valid approach, but you should note that your example doesn't actually create immutable objects due to the way you add subscriptions. I converted your example to F#, so you can appreciate how subtyping in F# has a very natural flow that makes writing types very similar to writing functions. type User internal(name:string, isActive:bool) =
member this.Name = name
member this.IsActive = isActive
type ActiveUser private(name:string, subscriptions:Subscription list) =
inherit User(name, true)
member this.Subscriptions = subscriptions :> IEnumerable<_>
member this.AddSubscription(s) = ActiveUser(name, s::subscriptions)
new(name) = ActiveUser(name, [])
type DeletedUser(name, deletedDate:DateTime) =
inherit User(name, false)
member this.DeletedDate = deletedDate
member this.Reactivate() = ActiveUser(name)In general, though, it turns out you often need to be able to have objects in an 'illegal' state some of the time. "Zip code is a mandatory field" - but that doesn't mean you can't save your order form for later if you need to go and look up the zip code. It's generally best to validate objects as being 'eligible' for a particular operation, not to prevent them from being ineligible altogether - An object with no zipcode can be saved, but it can't be submitted. Structuring that sort of thing into a type system - while doable - is probably not idiomatic in either F# or C#.
The main selling point of extreme late-binding (e.g. ad-hoc polymorphism, dependency injections, message passing, etc) is that lets you quickly revise your model.
[1] Whatever that means.
I know it's a made up example to showcase discriminated unions, but it becomes a bit of a strawman to suggest that C# doesn't even allow representing discriminated unions. It does: through inheritance and tons of boilerplate code.
The correct demo I think would have been to make the correct type (which doesn't allow invalid states to be represented) in both languages, to show how in C# it required maybe hundreds of lines, and in F# it's just a few lines.
Under the hood, almost exactly the same types are created from F#, where the F# types when seen in C# or IL of course look very much like the types you had to meticulously type in C#.
The point was to show how when you're thinking in a language that supports algebraic types and pattern-matching, you automatically exclude states that can't exist.
It's not that the underlying CLR or IL is any different given the same solution -- that's just measuring a language's verbosity. It's that some language constructs automatically lead you down the path to correct code without having to boil the ocean.
Sidebar: I would have tightened the F# code up a bit. No need to have the Name string repeated in both types.
Using a nullable field and an enum is hardly the standard way in C#.
A properly designed model in C# like suggested is a large number of lines of code. And then because there is no pattern matching, you have to hand-roll your type checks/casts and make sure have that method throw an unconditional exception at the end for literally impossible states. Then if you make a change to this model, i.e. you add a new "SuspendedUser" subclass, you have to make sure you go through the codebase to update all those places you wrote a hand-rolled "pattern match" against that type. Because the compiler won't warn you about an unrepresented state on your type checks. And so then you have to make sure the whole project team is aware and you have to make sure new hires are familiar with the patterns being used. So yeah, the problems just snowball and then you wonder why you didn't just use a more appropriate language in the first place that has already taken complete care of all these trivial programming issues.
That should be the default line of thought in OO too!
I admit it's easier to do with discriminated unions, but that doesn't change the fact that the C# class is still a common but poorly designed type in C#.
The job of any type system is to make invalid states unrepresentable. F# has powerful tools for that, and make it intuitive and succinct. C# has fewer and less powerful tools, but it's still possible (and usually necessary) to use those tools! The equivalence of F# DU's are C# class hierarchies, and the equivalent of pattern matching is virtual methods.
Inheritance and virtual methods is a really verbose and horrible way of going about it, but it's the way of going about it in C#. You have to use classes for the state here
Since there is a deleted time only useful for the "Deleted" state of the user the user state shouldn't be an enum. It's one of the pillars of decent OO design: a you don't have a field in a type that is only used for some state of the type: that state should then be a subtype. The possible state values for the user is ActiveState and DeletedState { DeletedTime }.
If a person wrote the C# class in that way out of ignorance rather than laziness, I bet they would write the F# type as a normal record type too!
I've come to the conclusion it is difficult to be fair when trying to introduce someone to an idea:
An APL-er recently mused that palindrome-detection was a good example of array-thinking, specifically: {⍵≡⌽⍵} (or in K: {x~|x})
However another programmer made a quick check of the Rosetta Code[1] and observed almost all of the examples used that same method without "array-thinking".
That is, unless we state that
x == reverse(x)
is succinct in many languages, it is easy be accused of being disingenuous, and we run the risk of letting the argument get away from us: After all, the atom solution actually requires fewer operations than the vector solution: for (i = 0, n = floor(length(x) / 2); i < n; ++i)
if(x[i] != x[n-(i+1)]) return false;
return true;
and despite being longer, may actually be faster if we ever need to test very large strings.C# programmers often use Contracts. Sometimes they use "if+printf-debugging", sometimes they use "lots of boilerplate code and class inheritance", and sometimes they invent their own runtime assert+log mechanism. The discussion about how C# programmers prevent programs from entering invalid states should not have to admit this any more than I should have to test for very large palindromes.
That is to say that contrived examples are contrived.
So let us observe that many C# users don't define a method:
delete :: User -> DeletedUser
They could, but they don't. Now why don't they?One of the bothersome issues is with a `delete :: User -> DeletedUser` method is you now have both a User and a DeletedUser, seemingly as valid as the other. Most languages share the issue too, the type-encoding of state change doesn't really work because an instance in the old state is still accessible, at best it might blow up at runtime.
That's a place where affine types (à la Rust) are an improvement, because with `delete(User) -> DeletedUser` you'd completely lose access to the User instance.
while(queue.Any())
next_queue.Enqueue(queue.Dequeue().Delete());
So, in this scheme you do lose access to the User instance once you call Delete().A robust solution would probably involve using the visitor pattern to dispatch the correct Enqueue method of next_queue (i.e. we may have multiple next_queues), but as a side benefit of all that boilerplate, refactoring becomes simple, and you can get concurrency for nearly free.
That's not really true. Code contracts are just a library , it's certainly not "traditional" in C# to use that to check for invalid state. Traditionally you just check the value before assignment. And if you do this your entire problem "goes away".
Gotta say this is one of the most contrived "F# is better than C#" example i ever saw.
Assignment checking is pretty simple but would not work if you want to verify the correctness of the program in compile time. Which you can still do just fine in C#.
You would use a class hirarchy with a `User` abstract class and `ActiveUser` and `DeletedUser` subclasses and use the type system to enforce the constraints. The syntax wouldn't even look worse.
If we're talking about a functional style then yes, `User`s would be immutable object, changing them would return a new user. Doing `activeUser.Delete()` returns a new deleted user, doing `deletedUser.Undelete()` returns a new active user. I might go as far as creating the user as a value type (struct) but that's a bit unorthodox.
But don't you now risk having two mutually contradictory objects representing one user?
Think of the following code:
if (n != null) {
n.do_something()
}
What happens if n gets deleted after the null check, but before the method invocation?So you are already assuming that you can do certain things atomically, even if, in theory, some external agent can write on your data. You manage that by protecting your data (with asynchronous tools or immutability), or by statically verifying your code.
I wouldn't say spreading null checks and forgetting them all over your code makes any problems "go away".
I remember when HN wasn't /.
The article tldr: Poor design is poor design.
Edit: Please, HN, do continue to prove my point. Idiots love their little echo chambers.
That's cool, but things get much more complicated when trying to describe the concept of a non-empty list (still possible, but I don't believe is included in F#'s library), or near impossible when trying to describe things like a number within a certain range, or various relationships between the arguments of a function or of a type.
I mean, when you're constructing something like "Segment(x, y)" (or "Segment of double * double" in F#, not sure if I got the syntax right), how do you express that the X coordinate needs to be lower than the Y coordinate? And then the biggest modern challenge is to model state machines for asynchronous communications, which implies working in the presence of non-determinism. Good luck modeling that with a static type system.
Basically there are things that can be expressed in a statically typed way, by means of a good enough type-system, but there are always invariants that can only be checked at runtime. Hence assertions sprinkled in the code are still valuable even in a language like F#, because if you're going to fail, it's better to fail as fast as possible.
I don't think async really matters at type-level. What matters is the failure-mode.
And Haskell seems to be doing this just fine, by abstracting IO into its own aspect which can either succeed or fail, and thus separates the domain-model from the failure-modes.
It's by no means impossible, and it's pretty defeatist to assume we all have to end up in some sort of dynamic non-typed nodejs-like blob as soon as you invoke Async or network operations.
Even C# handles this quite well, and fully typed all the way.
Much more interesting is to model actors / components that talk with multiple other actors / components concurrently and whose state and consequently the set of messages it can receive or emit is changing over time, depending on the state of the components it communicates with.
In Erlang's actor model it's not defeatist to say that the set of all possible messages an actor can receive can be as useful as Any or String in the face of an ever changing context, coupled with the fact that actors can expose different interfaces depending on to whom they talk to. Imagine a component that on one side receives signal readings determining if some physical asset is available or not and on the other side it accepts commands for that asset, but only if the asset is determined to be available. And the commands are numbers in a certain range that also varies over time depending on the signal readings.
And as a more recent example of where static typing fails are the transducers from Clojure. Same problem, as in ... modeling of state machines.
> And Haskell seems to be doing this just fine, by abstracting IO into its own aspect which can either succeed or fail
If you are talking about the I/O monad, I believe that the I/O monad sucks for doing actual I/O, though it's pretty cool for in-memory stuff. This is because it has baked-in the assumption that those operations are synchronous. Ironically it's the same big problem when you look at, say, the common Iterable pattern and compare it with monads. And I'm not up to date with latest developments, but Iteratees also suck.
BTW, I'm a big fan of static typing. I remember a time where I had a hard to reproduce bug that I couldn't understand and I fixed it by eliminating the possibility of it happening through the type system, which was sort of a revelation. But as with everything, there are limits in what you can express and I also believe in runtime constructs where it makes sense.
I think that much of the benefit of languages like F#/OCaml comes from immutability by default rather than from the type system. In fact, even in the example in the article, the very same (not working but nevermind) approach would have been idiomatic in C#/Java if they, too, were immutable by default. But, of course, immutability has its own costs (unless mitigated by linear types, which also don't come free).
data Segment = Segment Double Double
mkSegment :: Double -> Double -> Maybe Segment
mkSegment x y
| x < y = Just (Segment x y)
| otherwise = Nothing
There are things you can check at compile time, and for that F# and other strongly typed languages are great. And if that's impossible they can still force you to do these checks at runtime.And with such great support for custom types, you can rely on complex invariants in large parts of your application. In the example above, if you have a `Segment`, you know that x < y, even if the compiler can't prove it.
But that's because lots of people actually used them and came to the conclusion that they didn't want to manually handle every exceptional case at the call-site. People realized that if an operation has two outcomes, but one of them would cause the entire computation to fail and that outcome happened only 1/1000 of cases, they'd rather just ignore it, and catch any exception up the stack. If many people used Either/Maybe they might come to the same conclusion. So we don't know today if Either/Maybe work well just as we didn't know in 1995 if checked exceptions work well -- simply because the pattern hasn't seen enough use yet (and I have no reason to believe that the conclusion would be any different, namely that a possible though unlikely outcome might want to be handled collectively not near the call-site).
2- Either/Maybe are not for when an outcome "will fail in 1/1000 of cases", so this is an example of how they are unlike exceptions. If what you said was true, it wouldn't make sense for a hybrid language like Scala to have both. Obviously Scala's designers don't consider exceptions and Either/Maybe "identical", do they? (And no, exceptions aren't only used for Java compatibility, though their rampant use is discouraged).
3- You can silence checked exceptions with reckless abandon, but you cannot silence a Maybe just as easily, without resorting to obviously frowned-upon practices; you either handle Maybes by using library functions that already know how to handle them properly, or you handle them manually and you are forced to think about the outcome for each case. You cannot simply say "I don't know how to handle the None case, so I'll leave it blank" (because it won't compile) or "I'll throw an exception" (because that's not how you use Maybe, to the point absolutely no-one uses them like that, unlike the case in Java).
4- I'm sure you know there is plenty of bad stuff about checked exceptions beyond the obvious "lazy/rookie Java programmers swept them under the rug and forgot about them". For example, checked exceptions are terrible for generic code, composable code.
And there are more problems with them which don't happen with Either/Maybe. I truly don't see how you can seriously consider them "identical".
I don't. I used them first over 15 years ago when Haskell was "the next big thing" and I learned it at university. Fact of the matter is that not enough code has been written with them to say that we know how effective they are.
> If what you said was true, it wouldn't make sense for a hybrid language like Scala to have both. Obviously Scala's designers don't consider exceptions and Either/Maybe "identical", do they?
I wouldn't take lessons from Scala. Scala has inheritance types, sum types, structural types and dynamic types, each can be (and is) used in isolation to solve almost all problems. If there's any language with lots of overlapping features, that would be Scala. After all, it's a language designed to study various approaches, not to recommend the "best" ones.
> You can silence checked exceptions with reckless abandon, but you cannot silence a Maybe just as easily, without resorting to obviously frowned-upon practices
You can silence checked exceptions by doing exactly what you'd be doing to handle Maybe: You need to explicitly handle it. The difference is that you can catch multiple exception types in one block.
> For example, checked exceptions are terrible for generic code, composable code.
They're not. They just don't work too well with Java's limited effect system of checked exceptions. As a matter of fact, "checked exceptions" are the basis for effect systems in languages like Koka, and are also the basis of transformations of Haskell-ish monads to type-safe nested continuations, which are more appropriate than monads (yet equally expressive and powerful) for imperative languages.
Moreover, a system of checked exceptions composes much better than monads do. And you don't have to believe me. Phil Wadler says it[1] as does Oleg Kiselyov (who's trying to port the same idea back to monadic notation with free monads). This is my own treatment of the subject (which shows how continuations, of which exceptions are a special case, can do monadic composition without monad transformers, and provide type safety through checked exceptions): http://blog.paralleluniverse.co/2015/08/07/scoped-continuati...
> I truly don't see how you can seriously consider them "identical".
It's not a question of consideration. Wadler and Filinsky proved them to be the same (each from a different direction). See the link to my post above for the exact references.
Still, that's not to say that checked exceptions or Maybe are good approaches. We really don't have data on the subject. It basically comes down to personal preference.
Plenty of code has been written. You don't get to claim "fact of the matter" unless it's actually a fact. In reality you seem to be claiming "I don't have enough practical experience with them" which, while entirely respectable, it's a significantly different claim. How much code is "enough code" anyway, according to your standards?
> I wouldn't take lessons from Scala.
What I'm reading here is: "I don't like your example. I will also pretend to forget other languages which also have Maybe types and exceptions. Also, I'll ignore the part where Maybe/Either are not only used in exceptional 1-in-a-100 situations because it doesn't help my case."
> You can silence checked exceptions by doing exactly what you'd be doing to handle Maybe: You need to explicitly handle it.
How you handle Maybe and how you silence an exception is different in practice and in mindset. Because Maybe is usually used in expression-oriented languages (something which is somewhat at odds with exceptions), you need to provide a value for the None case, and this forces you to think about which value makes sense (and when you can't find one, it's usually a sign your function was poorly designed). Compare this with the all too common:
catch (Exception e) {
log.error("Think about this later, if I remember");
}
(And if you're already in this dangerous mindset and you must return a value, why, let's pick NULL!)Maybes make this approach tremendously uncomfortable, which is a good thing. Unlike in Java, blindly using something like fromJust is strongly frowned-upon.
In any case, you often don't handle Maybes explicitly; it's already handled by rich standard libraries in most languages that advocate the use of Maybe. Compare this to the non-standard chaos that is exception handling ("oops! what will this method do if this other thing throws? will it handle it correctly? I better read the source code"). Which means that in practice both approaches turn out to be quite different.
> They just don't work too well with Java's limited effect system of checked exceptions
"I don't like your example."
> It's not a question of consideration [that exceptions and Maybe/Either are "identical"]. Wadler and Filinsky proved them to be the same (each from a different direction)
Sorry, this is too similar to a Turing Tarpit argument. That two systems can be shown to be formally equivalent is very interesting but means little in practice. After all, code with lots of boilerplate and code with little boilerplate can be shown to be equivalent, but it's not identical in practice.
> Still, that's not to say that checked exceptions or Maybe are good approaches. We really don't have data on the subject
I wish you stopped saying this. Realize that "I don't have experience with this" is not the same as "the world doesn't have experience with this".
Assuming that you can make Segment's constructor private, that's indeed a good pattern sometimes, but leaving mkSegment as the only way to build a segment will make working with segments pretty inconvenient. Imagine that what you usually want to do with segments are intersections and unions and if the original segments already have this x < y invariant, then the algorithms, if correct, will also have it. Therefore forcing usage of mkSegment will be a pain in the ass, because then you have to deal with the Maybe monad everywhere.
As @pron is saying, this is still a runtime check. And the problem is, there is no need for it unless you receive outside input that needs to be processed. Otherwise the internal algorithms should always respect the contract anyway. Consider this ...
intersect :: Segment -> Segment -> Maybe Segment
What would a Nothing result mean? Would it mean that the 2 segments do not intersect, or would it mean that one of the segments was invalid? Treating the error in this signature is totally unneeded and forcing usage of mkSegment would do exactly that.In Scala I sometimes use the mkSegment pattern that you described when receiving data from the outside (like from the database), but in this particular instance I prefer doing the following, because of the above reasoning (i.e. Segments should already be valid, input should be validated elsewhere, if we've got invalid segments then we need to fail as fast as possible):
case class Segment(x: Double, y: Double) {
require(x <= y)
}
And I find this to be totally fine, in addition to whatever the static type-system can pull off. intersect :: Segment -> Segment -> Maybe Segment
> What would a Nothing result mean? Would it mean that the 2 segments do not intersect, or would it mean that one of the segments was invalid?The way I see it, it can only mean that they do not intersect, and never that one of the segments was invalid. This is because a Segment is guaranteed to be valid by construction; after all, that's precisely the property you bought by using mkSegment!
Let's see: because mkSegment is the only way to construct a Segment, you know the arguments to intersect are valid. So there cannot be any doubt about the meaning of Maybe in the result; it simply cannot refer to invalid arguments. There is only one other possible meaning: that the segments don't intersect, which is the idiomatic use of Maybe (another reason why I wouldn't use Maybe in this case to signal an invalid argument: "which argument?").
Yes, this means that even if there is an intersection, mkSegment forces you to handle the None case, even though you know by construction this will never happen. It's annoying but nothing too terrible. I don't see how this is cheating; you are simply using a property that the type system is unable to catch... but this property exists nonetheless.
round (1/0)
I would have preferred if it failed with a runtime exception, instead of giving the result that it does.I'm not even sure it achieves that. Without linear types (or "typestate"), if an operation (say, delete) actually modifies a database or a file -- as would normally be the case -- you can have outdated references of the wrong type (ActiveUser) pointing to the same DB record. They've just introduced a far worse runtime inconsistency (while staying perfectly type-safe)!
>Unfortunately, the lack of discriminated unions and pattern matching makes the F#’s approach mostly unbearable to implement in C#. It’s possible in some simple cases but still remains pretty tedious.
The C# idiomatic way would be to use an abstract class and implement two subclasses.
The difference is that in the C# way someone could in theory (if they're your evil wizard arch nemesis for example) create a third user subclass and harm the type soundness. In practice you can guard against that as well if you really have to guard against evil wizards.
F# has these best practices built in to its type system. The way the author is coding the domain models, there's no way for the model to be in an invalid state.
C#, on the other hand, does not give this safety in its type system (and doesn't make it easy to "abuse" the type system to provide it). One can use contracts[0] to do this, but it provides mainly runtime checking, not compile-time checking like F#.
[0]http://www.dotnetcurry.com/csharp/1172/code-contracts-csharp...
Back in the day we used to call it an assert, sprinkled it over code to cover the invariants and generally didn't make much fuss about it. Kids these days... "design by contract"... such words, much wow :-P
DbC formalizes the idea of a contract (hence the name) between the caller and callee (in Eiffel parlance, the client and supplier of a service) and what each expects of the other and is part of the interface of a class.
Accordingly, there are rules for contracts (visibility of symbols used in a contract must match the visibility of the function it is attached to, how contracts are related to each other via inheritance, etc.).
It is not a terribly complicated concept, but it's also not just asserts.
That's exactly what it is, but it's a very specific style of asserts with very specific rules as to what and when to assert; it is however, still just a bunch of asserts.
The other essential part that asserts don't really capture is that contracts (as pre- and postconditions) are part of the API, while asserts are part of the implementation.
You can't blame me because some language you choose has limited meta abilities and doesn't allow you to place asserts anywhere. An assert is a generic concept that you can do in any language, and in languages with meta-facalities all of that is trivial to do, you're too focused on "where" the assert is in your specific language, but DBC came from Eiffel, not C++, and checking conditions or blowing up is an assert no matter where it occurs. DBC is cool, the places DBC chooses to do it's asserts are also a big part of what DBC is, but it's still all just some asserts in creative places under creative inheritance rules.
But in the end, interface (contracts) and implementation (asserts) aren't actually the same thing. Having commonalities is not the same thing as being the same.
My biggest problem with OO is, that it only allows us to structure code in a one dimensional way (inheritance) but in reality we will face more complex scenarios. and even this way is increasingly discouraged: composition over inheritance...
[table labeled :Users:]
In my opinion that's a better way of encoding the desired behavior than either the C# or F# code by several orders of magnitude. It can be read to find out what privileges a user state allows. It can be read to find out what user states is required to access a privilege.The key is that tables are readable. By anyone. Even non-programmers. All the behavior is right there. The question about what's the best way to represent it in code is not F# versus C#. It's automatically versus wrong. The table should be the point of truth. [1]
[1]: The point of departure is Aaron Brooks' idea of live specification. I don't claim to accurately represent his view. Listen for yourself here: http://blog.cognitect.com/cognicast/074
Modeling is hard, maybe it should be done more accurately...
In general though, if there were 3 states (active, suspended, deleted) where some states required more data than others, I'd probably make a struct+enum to create the equivalent of the F# type. The user can expose the state while hiding the fact that it has a more complex internal field.
Only US users have a 'social security number' field. Should US users be a different class than German users?
Only cars have a 'passenger count' field. Only electric vehicles have a 'battery capacity' field. What type system can accommodate both Teslas and Harley Davidson LiveWire bikes?
Passenger number: "0" seems like a valid number that can make sense even for non-passenger cars.
I agree it's not black and white, though.
Which doesn't mean I dislike the whole concept, but it's way more limited than this article wants you to think.