-
Notifications
You must be signed in to change notification settings - Fork 21
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
Allow read access to records, but private construction #1122
Comments
Related: #810 |
|
The fact that I would recommend [<Struct>]
type PosFloat private (value:float) =
member _.Value = value
static member Create(value:float) =
if value <= 0. then failwith "Mut be positive" else PosFloat(value) |
I'm not attached to the particular keyword. I chose |
That's the expected behavior for records and discriminated unions, not a bug. I think this behavior is crucial to writing clean F# code.
Having to fall back to low-level code like this is exactly what I'm proposing we should avoid for such a basic use case. |
At our (my) company, we recently moved our "domain-heavy" code from Python to F#. Overall, we've been very happy with our decision. But, for a language whose selling points are correctness and conciseness, the absence of one obvious, simple way of creating constrained types is baffling. We've repeatedly felt the need for "destruct but don't construct" access. Scott Wlaschin shows a few ways to do this. Everything there is a compromise, and has felt like a kludgy trick to newcomers; you give up the ergonomics of records and discriminated unions (a huge part of why we enjoy F#), or haven't really forced use of your validating constructor, or write tediously duplicative code in active patterns (which are limited to seven cases; not enough when making a large domain-specific DU "read only"). We'd dearly love to see something like this. |
Thank you for those links. Using active patterns to access the fields is an interesting idea, but still seems like a clunky workaround, rather than a long-term solution. Scott Wlaschin mentions that signature files can be used to solve this, but I don't see how that would work either. There really doesn't seem to be any good way to do this yet in F# with algebraic data types (i.e. records and DU's). |
Just a note:
This is probably not accurate. This amounts to a new kind of access modifier, which is not an easy thing to add and carefully consider the design for. It's not just this, but it's carefully considering all the domain modeling scenarios, understanding which feels appropriate to bring into the core language or not, good diagnostics, and ensuring tooling behaves correctly. Parsing a keyword is just a very small part of this. Personally, I don't see this as being particularly valuable. This kind of flexibility around construction and visibility is exactly what objects provide, and @charlesroddie's suggestion is what I would do if I needed "read but not construct" permissions on a type. Since this is already supported by objects and structs in F# today, I don't think it's terribly important to bring into DUs and records. |
If this preserved type inference based on field names, I would probably accept it. Forcing users of my code to write |
There are also two other things to consider:
|
Since the possibility of bringing stronger information-hiding concepts to DUs/Records seems to be too far, how about going in a different direction? |
Personally, this is ideal. type String50 =
[<Validate(
fun s ->
if String.IsNullOrEmpty(str) then
Error [ "NullOrEmpty" ]
elif String.length str > 50 then
Error [ "Over 50" ]
else
Ok str)>]
| String50 of string In the other direction, what about a type like /// Unlike `Result<'t , 'error>` cannot be created freely.
type Validated<'dto, 'domain, 'error> =
private
| Valid of domain: 'domain
| Invalid of error: InvalidType<'dto, 'domain, 'error>
/// Manipulate `Validated<'dto, 'domain, 'error>` in this class.
type Domain<'dto, 'domain, 'error>
(
fromDto: 'dto -> 'domain,
toDto: 'domain -> 'dto,
validate: 'dto -> Result<'dto, 'error list>
) =
// The following is omitted
module Domain =
open DomainHelper
open System
// Omission
/// Nested cases
type Person =
private
{ First: String50
Last: String50
Birthdate: Birthdate }
type PersonDto =
{ First: ValidatedString50
Last: ValidatedString50
Birthdate: ValidatedBirthdate }
let person =
Domain<PersonDto, Person, string>(
(fun p ->
{ First = string50.Value p.First
Last = string50.Value p.Last
Birthdate = birthdate.Value p.Birthdate }),
(fun p ->
{ First = string50.ofDomain p.First
Last = string50.ofDomain p.Last
Birthdate = birthdate.ofDomain p.Birthdate }),
(fun p ->
match p.First, p.Last, p.Birthdate with
| Valid _, Valid _, Valid _ -> Ok p
| (BoxInvalid v), (BoxInvalid v'), (BoxInvalid v'') ->
(v @ v' @ v'')
|> List.map (fun e -> $"%A{e}")
|> Error)
)
It would be nice to have a function to assist in creating functions for domain types. open Domain
open System
let fooBar =
person.Create
{ First = string50.Create "Foo"
Last = string50.Create "Bar"
Birthdate = birthdate.Create(DateTime(1990, 3, 4)) }
let hogeFuga =
person.Create
{ First = string50.Create "Hoge"
Last = string50.Create "Fuga"
Birthdate = birthdate.Create(DateTime(2999, 2, 8)) }
module String50 =
let concat = string50.Lift2Dto(fun s1 s2 -> s1 + s2)
module Person =
let margeName =
person.Lift2Dto (fun p1 p2 ->
{ p1 with
First = String50.concat p1.First p2.First
Last = String50.concat p1.Last p2.Last })
let marged = Person.margeName fooBar hogeFuga
// val marged: DomainHelper.Validated<PersonDto,Person,string> =
// Valid { First = String50 "FooHoge"
// Last = String50 "BarFuga"
// Birthdate = Birthdate 1990/03/04 0:00:00 } |
Also related is #516 |
Also related is
Ideally anything in this area would follow the same rules we use for access control. However It's surprisingly hard to find remotely satisfying ways of declaraing this distiction in capabilities: For the "can I create the record" then this is reasonable: type PosFloat =
{
private new
Value : float
} But what of restricting the ability to access it? Do we allow access controls on the members? type PosFloat =
{
private Value : float
} Perhaps this is reasonable - though it's not clear if you could do this: type PosFloat =
{
public new
private Value : float
} That is construct but don't touch. Anyway I guess |
Enforcing a private constructor on the entire type (but leaving the ability to destruct public) with a @dsyme Could this also be available on discriminated unions? Again, hugely useful. Current workarounds (eg, hiding cases with |
@acowlikeobject Yes, in theory. What syntax? And would it be per-union-case or not? Per-union-case construction is the capability logicall corresponding to the type Union =
| private new A of string
| private new B of int Or this, doing all at once type Union =
private new
| A of string
| B of int The former is heading to a more complete state (access controls that correspond to the individual capabilities implied by the generated code for unions and records), the latter is simpler. |
Note attributes could also be used to prevent an expensive proliferation of new syntax. However that means accessibility becomes an awkward mix of attributes and keywords. Neither is ideal |
I'm definitely wading into the deeper end of the language pool than I ought to but if you will pardon me jumping in...I like per-union-case construction over per-union construction. It appears to more closely track common cases when creating constrained types. You can add validation members just like this just like we have in the past of course, but the bonus here is that you no longer have to maintain two types, both of which are just parts of one domain entity in various stages of its lifecycle.
Stealing some code from the linked example, we get an entire unvalidated->validated constrained type in just the type declaration. type EmailAddress =
| Unvalidated of string
| private new Validated of string
static member private isValid (value: string) =
// Validation code goes here
true
static member Validate(address: string) =
if EmailAddress.isValid address then
Validated address
else
Unvalidated address
member this.Value =
match this with
| Unvalidated value -> value
| Validated value -> value Now any functions that want to take only valid emails can just pattern match on a given EmailAddress to see if the union case is Validated or not. We no longer need a separate (private create) ValidatedEmail type to make the illegal state unrepresentable. |
@dsyme I'd vote for simplicity and have DUs are fantastic for modeling state machines, where you want to inspect state in several places (public destruction), but control transitions (private construction). I'm not sure case-level control adds much in those use cases. You'd probably end up marking all or all-but-initial cases as private. On the other hand, it feels pretty natural to have a type-level But obviously, you can accomplish strictly more with case-level control, so I'd still be thrilled if that's what landed. It just seems to use up the complexity budget for not a lot of additional value? @aneumann-cdlx I'd say |
@dsyme I also agree with @acowlikeobject. @acowlikeobject If this feature is realized, pattern matching will be very easy, so combining it with type EmailAddress =
private new
| EmailAddress of string
static member private isValid (value: string) =
// Validation code goes here
true
static member TryCreate(address: string) :Result<EmailAddress,string>=
if EmailAddress.isValid address then
Ok (EmailAddress address)
else
Error address
static member TryCreateWith onError (address: string) :Result<EmailAddress,'error>=
if EmailAddress.isValid address then
Ok (EmailAddress address)
else
Error (onError address) updated 2022/06/27:
type IValidatable<'dto,'t> =
abstract static member Validate :('t -> 'validated) -> ('dto -> 'validated) -> 'dto -> 'validated
module Validatable =
let validate<'Validatable when 'Validatable :> IValidatable<'dto,'t>> onValie onInvalid value =
'Validatable.Validate onValid onInvalid value
let create<'Validatable> x =
validate<'Validatable> id (failwithf "Invalid: %A") x
let createResult<'Validatable> x =
validate<'Validatable> Ok Error x
let createOpt<'Validatable> x =
validate<'Validatable> Some (fun _ -> None) x
type EmailAddress =
private new
| EmailAddress of string
static member private isValid (value: string) =
// Validation code goes here
true
interface IValidatable<string,EmailAddress> with
static member Validate onValid onInvalid address =
if EmailAddress.isValid address then
onValid (EmailAddress address)
else
onInvalid address
type PosFloat =
{
private new
Value : float
}
interface IValidatable<float,PosFloat> with
static member Validate onValid onInvalid value =
if value <= 0.0 then
onValid {Value = value}
else
onInvalid value |
I'm going to close this in favour of #852, which we can use to track this general issue |
In order to support input validation, I think it would be useful to declare a type whose fields can be accessed, but which cannot be directly created by users.
Problem
We often need to validate input when constructing a value of a particular type. For example, let's say we want a type that represents positive floating-point numbers:
(Note that this could be represented by either a record or a discriminated union. I've chosen to use a record type here, but the same discussion applies equally to DUs.)
We could then use this type as follows:
We want to make invalid states unrepresentable, so we need to prevent code like this:
To do this, we make the fields private and provide a
create
function instead:But now users can't access the internal value themselves:
How can we fix this?
Workaround
One approach is to provide a separate member for accessing the internal value:
But this has some major disadvantages:
Proposal
I think it would be useful to declare a type whose fields can be accessed, but which cannot be directly created by users. Something like this:
I've used
protected
here, but feel free to substitute another keyword of your choice. Users would then be able to access theValue
field but not able to use it to construct a value of the type:Again, note that the same proposal applies to discriminated keywords:
Pros and Cons
The advantages of making this adjustment to F# are developers have a low-friction way to validate input when constructing F# records and discriminated unions.
The disadvantages of making this adjustment to F# are it requires a keyword, which is another thing that developers have to remember.
Extra information
Estimated cost (XS, S, M, L, XL, XXL): Small. It's a single keyword that's easily parsed and enforced.
Affidavit (please submit!)
Please tick this by placing a cross in the box:
Please tick all that apply:
For Readers
If you would like to see this issue implemented, please click the 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.
The text was updated successfully, but these errors were encountered: