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

Add "Make .Is* discriminated union properties visible from F#" #402

Merged
merged 4 commits into from
Jan 17, 2020
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions RFCs/FS-1079-union-properties-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# F# RFC FS-1079 - Make .Tag and .Is* discriminated union properties visible from F# #

The design suggestion [Make .Tag and .Is* discriminated union properties visible from F#](https://github.com/fsharp/fslang-suggestions/issues/222) has been marked "approved in principle".

This RFC covers the detailed proposal.

# Summary

F# discriminated unions have a number of generated public members that were created for C# interoperability and are hidden from F# code. These include, for example, an instance property `IsFoo : bool` per case `Foo`.

This proposal is to make these members visible and usable from F# code too.

# Motivation

It is sometimes useful to simply determine whether a value of a discriminated union is an instance of a given case, without needing to do anything more with its arguments. For example:

```fsharp
type Contact =
| Email of address: string
| Phone of countryCode: int * number: string

type Person = { name: string; contact: Contact }

let canSendEmailTo person =
match person.contact with
| Email _ -> true
| _ -> false
```

Considering that F# already generates a member `IsEmail : bool` which does exactly the same as the above, it would make sense to make it available to F# developers so that the example becomes:

```fsharp
let canSendEmailTo person =
person.contact.IsEmail
```

# Detailed design

The compiler generates the following public members for every discriminated union type:

1. A property per case named `IsFoo : bool` for a case named `Foo`. This property is true if `this` is an instance of `Foo` and false otherwise.

2. A property `Tag : int` whose value identifies the case: it is `0` if `this` is an instance of the first case (in type declaration order), `1` if `this` is an instance of the second case, and so on.

3. A nested static type `Tags`.

4. Inside `Tags`, a constant integer static field per case, with the same name as the case. Its value is the same as the `Tag` property of an instance of the corresponding case.

5. Either a static property `Foo` or a static method `NewFoo` per case named `Foo`, depending on whether `Foo` has arguments or not, which constructs an instance of this case.

In this design, members #1 through 4 are made available to F# code as well. #5 is kept hidden from F#, because it is equivalent to the corresponding case constructor and would be redundant and confusing.

Here is an example union type declaration with all of the newly F#-visible members listed explicitly (in pseudo-code, since nested `type` declarations are invalid F#):

```fsharp
type Example =
| First
| Second of int * string

// The following members were already generated, and are now visible to F#:

member IsFirst : bool

member IsSecond : bool

member Tag : int

type Tags =

val First : int

val Second : int

// The following members were already generated, and remain hidden from F#:

static member First : Example

static member NewSecond : int * string -> Example
```

# Drawbacks

Exposing `Tag` and `Tags` changes whether reordering the cases of a union type declaration is a breaking change:

| | Before this change | After this change |
| :---------------------------------- | :------------------: | :-----------------: |
| Binary compatible for F# consumer | No | No |
| Source compatible for F# consumer | Yes | No |
| Binary compatible for C# consumer | No | No |
| Source compatible for C# consumer | No | No |

# Alternatives

An alternate design would be to only expose the `Is*` properties but not the `Tag` property and the `Tags` nested type. This would eliminate the drawback regarding case reordering, while still exposing what is arguably the most useful generated members.

# Compatiblity

* Is this a breaking change?

No. It was already illegal to explicitly declare members whose name clashes with these generated members.

It does however change what constitutes a breaking change in F# code (see Drawbacks above).

* What happens when previous versions of the F# compiler encounter this design addition as source code?

It fails to compile any invokations of the generated members.

* What happens when previous versions of the F# compiler encounter this design addition in compiled binaries?

They see a normal discriminated union, and do not allow invoking its generated members.

* If this is a change or extension to FSharp.Core, what happens when previous versions of the F# compiler encounter this construct?

It is not directly an extension to FSharp.Core, but it does apply to union types declared in FSharp.Core: `option`, `Result`, `Choice`. Previous versions of the F# compiler do not allow invoking the generated members on these types.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't true for Option<'T> or ValueOption<'T>. See here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option<'T> is easy: IsSome and IsNone are already visible, so there is no change.

For ValueOption<'T>, there are also visible IsSome and IsNone, in addition to the hidden IsValueSome and IsValueNone. So there are two possible routes:

  • make them visible (which makes them redundant with the existing IsSome/IsNone)
  • keep them hidden (which makes ValueOption<'T> unique among all union types for not having Is{{UnionCaseName}} properties)

I think I'm in favor of the former, for consistency. Plus it's probably simpler to implement than making an exception.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume we can't remove the existing IsSome/None, but adding IsValueSome/None would effectively make them aliases of the former, in which they would then probably be unique in our ecosystem.

It's a tough call. For parity you would like to have them, but different members that do the exact same thing brings confusion, and unless the tooltip would clearly describe that they are synonyms, I'm afraid programmers will have to research what the differences are and you'll end up answering questions similar to whether String or string has better performance / should be preferred / has different semantics etc.

Of course, the answers will be simple, they're equal, but I think it's better to prevent the questions rising in the first place.

But then there's the argument of parity, auto generated code, predictability, orthogonality etc. It's a tough call ;).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the exact stance on obsoleting API is, but it could be acceptable to mark the existing ValueOption IsSome/IsNone properties [<Obsolete>] to reduce confusion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Tarmil I think your preference here is spot on: hiding IsValueSome/IsValueNone for ValueOption. Can you make a note of that in the RFC?


# Unresolved questions

N/A