Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New type: constrained type #553

Closed
5 of 6 tasks
Richiban opened this issue Mar 23, 2017 · 13 comments
Closed
5 of 6 tasks

New type: constrained type #553

Richiban opened this issue Mar 23, 2017 · 13 comments

Comments

@Richiban
Copy link

Richiban commented Mar 23, 2017

I propose we introduce a new type with structural equality that simply wraps one or more values but, more importantly, allows one to constrain the values that are allowed. For example, imagine trying to introduce a type into your domain to represent a quantity. You might be tempted to write:

type Qty = int

or

type Qty = Qty of int

But the problem is that we also have the requirement that any quantity in this domain must be greater than or equal to zero, and also less than or equal to 99. There is no way of capturing that requirement in F#.

My proposal is to add a new syntax for defining a type, and I call it a constrained type:

type Qty = int as value when value >= 0 && value <= 99

This syntax will essentially generate a single-case union type called Qty with the associated constructor function / pattern:

let qty = Qty 5
let (Qty i) = qty

so it's exactly as normal, except that calling let x = Qty (-1) will cause an InvalidArgumentException to be thrown.

The existing way of approaching this problem in F# is to either hack around with trying to make single-case unions private and reintroducing member properties and writing a static method to simulate a constructor:

	module Qty =
	    type T = private Qty of int with
	    static member Create qty = 
	        if qty >= 0 && qty <= 99 
	        then Qty qty
	        else invalidArg "qty" (sprintf "%i is not a valid Qty" qty)
	        
	    member this.Value = let (Qty qty) = this in qty

This sort of works but involves a fair amount of boilerplate code (which always increases the risk of introducing a bug) and leaves us in the unsatisfactory situation that Qty is actually not a type--it's a module. Qty.T is the type.

The other alternative is to simply fall back to OO:

    type Qty(qty : int) =
        do
            if qty < 0 || qty > 99 
            then invalidArg "qty" (sprintf "%i is not a valid Qty" qty)
            
        member this.Value = qty

but then you lose all the structural equality that you get from an F#-native type.

Pros and Cons

The advantages of making this adjustment to F# are that problem domains can be more accurately modeled by encapsulating the requirements of a value in the type itself.

The disadvantages of making this adjustment to F# are that it's work to do and another syntax for F# users to learn. I think it's incredibly readable though.

Extra information

Estimated cost (XS, S, M, L, XL, XXL): M

Affadavit (must be submitted)

Please tick this by placing a cross in the box:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.

Please tick all that apply:

  • This is not a breaking change to the F# language design
  • I would be willing to help implement and/or test this
  • I or my company would be willing to help crowdfund F# Software Foundation members to work on this
@theprash
Copy link

I think this is how I would actually do this. Somehow it feels cleaner to me but it has a similar amount of boilerplate:

[<AutoOpen>]
module Qty =
    type Qty = private Qty of int
    let (|Qty|) (Qty q) = q

    module Qty =
        let tryMake qty = 
            if qty >= 0 && qty <= 99 
            then Ok (Qty qty)
            else Error (sprintf "%i is not a valid Qty" qty)

Qty.tryMake 10 // Ok (Qty 10)
Qty.tryMake 100 // Error "100 is not a valid Qty"

let f (Qty (q:int)) = q // compiles
  • This uses an active pattern to give the desired syntax for accessing the wrapped value.
  • Given that the overall aim is to provide more type safety, I think returning a Result rather than throwing an exception is more in line with the style of this proposed feature.

There are several valid approaches and I think adding this feature as it is would be too prescriptive. And also, if there are multiple arguments during creation, how would the compiler know which ones were invalid? Presumably, the test would be an arbitrary boolean expression that could compare multiple inputs, launch missiles etc.

@Richiban
Copy link
Author

Richiban commented Mar 23, 2017

@theprash Thanks for your input! However that's not really any more readable...

Also, your example doesn't compile for me. The inner module Qty declaration gives a compiler error:

Duplicate definition of type, exception or module 'Qty'

@theprash
Copy link

theprash commented Mar 23, 2017

That sample only works in F# 4.1. Otherwise you need to add [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] above the second module and define your own DU with Ok/Error.

I agree it's still not pretty but I think it provides a better API. Perhaps still not good enough to be baked into the language.

@cloudRoutine
Copy link

@Richiban records are the way to go for what you want

type [<Struct>] Qty = private {
    Value:int
} with
    static member Create qty = 
        if qty >= 0 && qty <= 99 
        then { Value = qty }
        else invalidArg "qty" (sprintf "%i is not a valid Qty" qty)

but as for this feature I'm not if favor of it. This would just be a poor man's dependent type without any compile time constraints.

this mainly seems to a way to reduce boilerplate which could be addressed by type providers once they are able to generate F# types and use types as input

@Richiban
Copy link
Author

@cloudRoutine I agree, this kind of is dependent types but without the compile-time checking. Since I very much doubt we're going to be getting dependent types any time soon, I'd still like to have the checking at runtime. Who knows? Maybe the syntax could even be shared between the two.

Unfortunately your record solution doesn't solve the problem: using the type you created I can still bypass the Create method completely and write

let invalid = { Qty.Value = -1 }

This compiles fine and throws no error at runtime.

@cloudRoutine
Copy link

@Richiban you sure about that?

@Richiban
Copy link
Author

@cloudRoutine Ahh... it turns out that you have to put the type in a different module. Then the field is inaccessible.

So that's fine, but for your type you would also need to write the public property in order to be able to get the int field out again.

@Richiban
Copy link
Author

I guess I want to be clear when I ask for this feature I'm not asking for dependent types or anything complicated like that...

All I want is to be able to write a (structurally equal type) in F# that will protect its invariants.

I would accept a record type that allowed a do block so that code can be written in the constructor of the type. E.g.

type Qty = {
    value : int
} do
    if value < 0 || value > 99 then invalidArg "value" "That is not a valid quantity"

This would roughly mirror the way one would add code to the body of the constructor in a class:

type Qty(value : int) =
    do
        if value < 0 || value > 99 then invalidArg "value" "That is not a valid quantity"

    member this.Value = value

Except the type is still a record, and therefore gets the structural equality etc for free that you would have to write yourself in the class example.

@dsyme
Copy link
Collaborator

dsyme commented Mar 24, 2017

@Richiban Possibly the simplest path to this is to allow [<StructuralEquality>] and [<StructuralComparison>] on simple class types.

Specifically, class types where there are no extra captured fields implied by the (method-captured) let bindings of the class type - or we specify that such extra captured fields are ignored for the purposes of these attributes.

I believe it's actually quite simple to implement this. It does help reduce the dissonance between classes and records (without requiring us to add more and more class-like features to records)

So:

[<StructuralEquality>]
type Qty(value : int) =
    do if value < 0 || value > 99 then invalidArg "value" "That is not a valid quantity"
    member this.Value = value

or this (note someFunction is a method, and no fields are implied, per the F# spec):

[<StructuralEquality>]
type Qty(value : int) =
    let someFunction () = do-something ... value ... do-something
    do if value < 0 || value > 99 then invalidArg "value" "That is not a valid quantity"
    member this.Value = value

or this (note value2 is local to the construction, and no fields are implied, per the F# spec):

[<StructuralEquality>]
type Qty(value : int) =
    let value2 = value % 100
    do if value2 < 0 || value2 > 5 then invalidArg "value" "That is not a valid quantity"
    member this.Value = value

The only issue is about the meaning this:

[<StructuralEquality>]
type Qty(value : int) =
    let value2 = rand()
    member this.Value = value
    member this.Value2 = value2

Does value2 get taken into account for structural equality? I don't know what people would expect, so we should probably outlaw it, or at least warn that value2 will be ignored for the purposes of structural equality,

Also, there would be a requirement that all arguments to the constructor are type-annotated, rather than type inferred, as for structs today, though I'm sure you're happy with that.

@dsyme
Copy link
Collaborator

dsyme commented Mar 24, 2017

@Richiban If you're happy with that proposal then please change the title, and I think we can consider it approved-in-principle. It's one of the cleanup items I've been meaning to do for a long time, to complete the matrix of possibilities

@Richiban
Copy link
Author

Richiban commented Mar 25, 2017 via email

@Richiban
Copy link
Author

I have opened a new issue (#554) as I felt that modifying this issue to reflect the new solution to the problem would leave a large number of comments that no longer made sense. I will close this issue now.

Thanks all for your input!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants