Answer to the first question: no, you cannot get rid of upcasting every time. F# doesn't do automatic type coercion (which is a good thing), and all match
branches must have the same type. So the only thing to do is to coerce manually.
Answer to the second question: discriminated unions represent the "closed world assumption" - that is, they are good when you know the number of different cases upfront, and you're not interested in extending them later (your world is "closed"). In this case, you can have the compiler help you make sure that everybody working with your thing handles all the cases. This is super powerful for certain applications.
On the other hand, sometimes you need to design your thing in such a way that it can be extended later, possibly by an external plugin. This situation is often referred to as "open world assumption". In this case, interfaces work. But they are not the only way.
Interfaces are nothing more than records of functions, with the exception of method genericity. If you're not interested in generic methods and you're not planning on downcasting to specific implementations later (which would be a bad thing to do anyway), you can just represent your "open world" thing as a record of functions:
type Controller = {
``type``: ControllerType
controlSomething: ControllableThing -> ControlResult
}
Now you can create the different types of controllers by providing different controlSomething
implementation:
let init ``type`` =
match ``type`` with
| Basic ->
let rom = Array.zeroCreate 0
{ ``type`` = Basic
controlSomething = fun c -> makeControlResult c rom }
| Advanced1 | Advanced1RAM | Advanced1RAMBattery ->
let ram = Array.zeroCreate 0
let rom = Array.zeroCreate 0
{ ``type`` = ``type``
controlSomething = fun c -> makeControlResultWithRam c rom ram }
| Advanced2 ->
let romMode = 0xFFuy
let rom = Array.zeroCreate 0
{ ``type`` = ``type``
controlSomething = fun c -> /* whatever */ }
Incidentally, this also gets rid of upcasting, since now everything is of the same type. Also incidentally, your code is much smaller now, since you don't have to explicitly define all different controllers as their own types.
Q: Wait, but now, how do I get access to ram
and rom
and romMode
from outside?
A: Well, how were you going to do it with the interface? Were you going to downcast the interface to a specific implementation type, and then access its fields? If you were going to do that, then you're back to the "closed world", because now everybody who handles your IController
needs to know about all implementation types and how to work with them. If this is the case, you would be better off with a discriminated union to begin with. (like I said above, downcasting is not a good idea)
On the other hand, if you're not interested in downcasting to specific types, it means that you're only interested in consuming the functionality that all controllers implement (this is the whole idea of interfaces). If this is the case, then a record of functions is sufficient.
Finally, if you are interested in generic methods, you have to use interfaces, but you still don't have to declare everything as types, for F# has inline interface implementations:
type Controller =
abstract member ``type``: ControllerType
abstract member genericMethod: 'a -> unit
let init ``type`` =
match ``type`` with
| Basic ->
let rom = Array.zeroCreate 0
{ new Controller with
member this.``type`` = Basic
member this.genericMethod x = /* whatever */ }
// similar for other cases
This is a little more verbose than records, and you can't easily amend them (i.e. no { ... with ... }
syntax for interfaces), but if you absolutely need generic methods, it's possible.