fsharp / fslang-suggestions

The place to make suggestions, discuss and vote on F# language and core library features
345 stars 21 forks source link

Strict type aliases #1098

Open En3Tho opened 2 years ago

En3Tho commented 2 years ago

Often when modeling domain cleanly or trying to refactor some legacy code, I (and my friends / collegues) use type aliases to bring more "context" to function/type w.e. signature

The problem is that compiler still doesn't help with reducing mistakes this way: eg programmer is actually on his own. Aliased type still can be interchanged when consumed: eg.

type UserName = string
type UserLogin = string

let consumeUserName (userName: UserName) = ...
let userLogin: UserLogin = "myLogin"

consumeUserName userLogin ... // <- valid code, compiler cannot really help

Compiler already has a great way to help in similar case: Units of Measure, eg. int<kg> cannot be mistaken with int<sec> from compiler's point of view.

I'm proposing an Attribute which will tell compiler to see those types as something like base type + hidden unit of measure. Something like:

type UserName = [<StrictAlias>] string
type UserLogin = [<StrictAlias>] string

let consumeUserName (userName: UserName) = ...
let userLogin: UserLogin = "myLogin"

consumeUserName userLogin ... // <- invalid code

let myName: UserName = userLogin // something like an "explicit cast", manually tell compiler to see this value as UserName instead of UserLogin

consumeUserName myName... // <- valid code now

I think casting syntax isn't ideal here. But it still solves my problem at least. It needs to be given much more thought, to make it explicit but at the same time not too boilerplaitish I guess.

The existing way of approaching this problem in F# is well, be careful about it. Also, there is UMX, but it doesn't cover all cases and I do not think that units of measure are really great fit here. They were designed for a different thing after all. Some people use Single case DU for this but I think they still have this boilerplate problem. Also, forgetting to put [<Struct>] attribute can potentially lead to huge increase in allocations.

Pros and Cons

The advantages of making this adjustment to F# are ...

I think the main advantage is type safety. Reducing the chance of using invalid type.

The disadvantages of making this adjustment to F# are ...

Not like disadvantages but more like what this proposal is not trying to solve: This doesn't make the code any safer to outside consumers eg. C# People still have to remember that this is just an alias, strict or not, e.g. registering an aliased type in DI will result in registering base type etc.

Extra information

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

I think it's something around M, as compiler already has infrastructure for this feature.

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 :+1: emoji on this issue. These counts are used to generally order the suggestions by engagement.

dsyme commented 2 years ago

Would the methods like "Trim()" be lifted? What would they return?

That is the problem - it's not just a type, it's a type with operations. We don't get far without defining how those operations are transformed. It's the same problem with allowing UoM on string

En3Tho commented 2 years ago

Well, personally I think that we shouldn't really touch operations at all, e.g. let Trim() return the same thing as aliased type does. But the key difference will be this:

type UserName = [<StrictAlias>] string

let bob: UserName = "Bob"
let alice: UserName = "Alice"

bob.Replace("o", alice) // <- doesn't work as it's a strict alias, you have to explicitly convert strict alias to string

let alice: string = alice // <- explicit convert from aliased type to base
bob.Replace("o", alice) // now works

E.g. strict aliased types coudn't just be interchanged with each other or base type without explicitness.

I only talked about UoM for reference, in fact I believe UoM has completely different idea (eg. safe calculations, UoM combinations e.t.c.). Here it's more about call correctness/strictness.

dsyme commented 2 years ago

you have to explicitly convert strict alias to string

If we need to be explicit, why is it better than this?

[<Struct>]
type UserName = UserName of string

At least with that it becomes clear how to convert in/out of the type, and

The reasons in favour of making the representations iddentical would be things like "it looks better in the debugger" or "it looks like a string to reflection". But do you really want to be able to cheat the system as easily as this?

let x : UserName = "a"
box x :?> string
En3Tho commented 2 years ago

Well, my problem with DU's usually is that they require having "stuff" around them: serialization, implementing interfaces, e.g. full decoration if you need to mimic the type they wrap.

The boxing example can already cheat existring TypeAliases and UoMs. The same thing with Unchecked. I do not feel like having a battle with those features at compile time because they can't really be fought this way..

I guess the scope of this proposal is to have a way to introduce more type corretness at very little expense.

But at the same time I understand that all this explicitness talk might result and a very similar way of wrapping and uwrapping things, basically like those DU's do. And it's not clear without better examples/better syntax how this would be THAT MUCH better.

Frassle commented 2 years ago

you have to explicitly convert strict alias to string

If we need to be explicit, why is it better than this?

[<Struct>]
type UserName = UserName of string

At least with that it becomes clear how to convert in/out of the type, and

I think the issue with that is the much higher friction getting values out of the wrapper type. E..g taking the Replace example from above you can end up with something like:

[<Struct>]
type UserName = UserName of string

let bob = UserName "Bob"
let alice = UserName "Alice"

let newStr = 
    let (UserName str) = bob in str.Replace("o", let (UserName str) = alice in str)

Which contrasted with a way to just cast in and out which might look something like:

type UserName = [<StrictAlias>] string

let bob: UserName = "Bob"
let alice: UserName = "Alice"

let newStr = (bob :> str).Replace("o", alice :> str)

I've used the DU struct type a lot and the safety is worth it, but it would be nice to reduce the friction getting single values out. Off the top of my head would an attribute to expose Value work?

[<Struct; ValueProperty>]
type UserName = UserName of string

let bob = UserName "Bob"
let alice = UserName "Alice"

let newStr = bob.Value.Replace("o", alice.Value)

Obviously only valid on single case DU's with a value.

TheJayMann commented 2 years ago

Rather than use a struct single case discriminated union, you could instead use a struct single field record.

[<Struct>]
type UserName = {
    Value: string
}

let bob = { UserName.Value = "Bob" }
let alice = { UserName.Value = "Alice" }

let newStr = bob.Value.Replace("o", alice.Value)

SharpLab.io appears to indicate that a struct single case discriminated union results in effectively identical enough generated code as a struct single field record.

dsyme commented 2 years ago

The overall request seems to be a way to define a type that gives one word injection and .Value projection

UserName x
x |> UserName 
x.Value

and maybe a pattern too

UserName x

It's an interesting reasonable request. We could realistically safely provide an automatic .Value if one is not provided already for this:

[<Struct>]
type UserName = UserName of string

which then gives you what you want, though it's still a little too magical/implicit for my liking given that member x.Value = (let (UserName v) = x in v) is fairly short.

The other thing is that auto-generated properties for single-case unions are also reasonable, so maybe

[<Struct; AutoProperties>]
type UserName = UserName of value: string

Could give x.Value if we're happy to implicitly capitalize (ugh...). That would then apply to multi-data single case unions too:

[<Struct; AutoProperties>]
type UserName = UserName of value: string * name: string
Happypig375 commented 2 years ago

@dsyme With #752,

member x.Value = (let (UserName v) = x in v)

can be

member (UserName v).Value = v
En3Tho commented 2 years ago

@dsyme Hmm. Just a thought. Could compiler just issue an optional warning when a different aliased type is used at a call site?

cmeeren commented 2 years ago

I don't particularly like standardizing on .Value; it's too easy to break if adding or removing an option wrapper for this type. If you consume .Value in a weakly typed manned (.ToString(), sprintf "%O", obj, etc.), the code still compiles if you add/remove an option wrapper since it too has a .Value property, but the behavior is now incorrect. I've been bitten by this in the past enough times that I no longer call the property .Value.

pblasucci commented 2 years ago

I more-or-less always use explicit conversion (either op_explicit, .To<target-type>(), or both) for accessing the underlying value of a wrapped primitive. This avoids the issue @cmeeren mentions. It also encourages consumers to think of the type as its own nominal "thing" (so, stronger modeling than a simple type abbreviation). In fact, I'd say any time you don't want an abbreviation and it's source type to be interchangeable, you really shouldn't use an abbreviation. So, this strikes me as being more like Haskell's newtype feature, which has been dismissed in the past. So it may be worth revisiting the whys and wherefores.

piaste commented 2 years ago

I know that @En3Tho mentioned it and I agree that they're a hack, but personally I use FSharp.UMX and I'm pretty happy.

In addition to open FSharp.UMX I use the new open type FSharp.UMX.UMX statement. This way I can explicitly the tag and untag methods instead of the usual 'jolly' operator %, which can be confusing (it can both tag and untag implicitly, it cannot have type parameters, and it's difficult to search for).

I recommend anybody interested in a similar syntax try it, as it's really close to what this proposal is aiming for. At the very least it should give useful experience for the purpose of designing a built-in, universal feature.

@Frassle's proposed syntax was:

type UserName = [<StrictAlias>] string

let bob: UserName = "Bob"
let alice: UserName = "Alice"

let newStr = (bob :> str).Replace("o", alice :> str)

With UMX, it looks like:

open type FSharp.UMX.UMX

type [<Measure>] user
// optionally:
// type UserName = string<user>

let bob = tag<user> "Bob"
let alice = tag<user> "Alice"

let newStr = (untag bob).Replace("o", untag alice)