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

Allow Record Option fields to be... optional #918

Closed
4 of 5 tasks
absolutejam opened this issue Sep 4, 2020 · 11 comments
Closed
4 of 5 tasks

Allow Record Option fields to be... optional #918

absolutejam opened this issue Sep 4, 2020 · 11 comments

Comments

@absolutejam
Copy link

absolutejam commented Sep 4, 2020

After a brief discussion in Slack, I thought it'd bring it up here!

I've been toying around replacing some cumbersome YAML configs and F# has been great so far due to its type-safety, terse syntax and ability to provide more than just static configuration.

But one minor annoyance is the case of a record with Option fields. Ideally, I'd like to be able to:

  • Supply the Some value without the Some (it's implicit)
  • Omit optional fields, allowing them to fall back to None

Obviously, this would be opt-in, possibly via. an annotation on the entire record, or the field, or prefixing a field name with a ?, similar to the POCO style (eg. ?Name).

For example:

[<ImplicitOptionFields>]
type SomeConfig =
    {
        Name: string
        Description: string option
        Tags: string list option
    }

let x = { Name = "James"; Tags = [ "a"; "b" ] }
// `Description` defaults to `None`
// `Tags` does not require `Some`

The existing way of approaching this problem in F# is to...

  • Use a POCO type and leverage the in-built optional fields syntax (eg. ?description: string) - This means you're either using a POCO now, or writing another type to generate your record, and not leveraging the awesome record initialisation syntax.

  • Use a computation expression and mock the entire record initialisation syntax using custom operations - Cumbersome to implement, especially for large records, but provides the most friendly API for the consumer.

  • Create a 'default' version of the record that already has all optional fields filled with a None value. This has the down-side that this the mandatory fields still need to be populated somehow, eg.

type SomeConfig with
    static member Default name =
        { Name = name; Description = None; Tags = None }

This approach works fine if only a few fields are mandatory and the rest are optional. If all the fields are optional, then the 'default' can be all Nones, hurray!

But this soon becomes a problem in itself when many fields are optional and many are mandatory, and might lead to using a 'SomeConfigMandatoryFields`/anonymous record to fill in the blanks.

Pros and Cons

We already have this concept available for POCOs, and if there was some way to implement this for the record initialisation syntax, I feel like it'd be a great extension to writing custom APIs.

I've looked at Dhall (Haskell based configuration langauge) which suffers from the same issues, and I've been using jsonnet for a while and in all honesty, it's an awful experience. I'd much rather leverage F#'s friendly, familiar syntax and all of the power dotnet behind it.

Currently, I'm using computation expressions to bridge this gap, but it does mean a whole lot of extra work for large records and the syntax could be confusing at first if somebody is just expecting a record (It's kinda overkill just to create a record).

someConfig {
    name "James"
    tags [ "a"; "b" ]
}

The disadvantages of making this adjustment to F# are it being unclear at first glance when looking at records created in such a way. This could be remedied by some information in the IDE popup?

Extra information

Estimated cost (XS, S, M, L, XL, XXL): I couldn't say in all honesty 😬

Related suggestions: (put links to related suggestions here)

Affidavit (please submit!)

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 or my company would be willing to help implement and/or test this - It'd be my limited knowledge & time I'm afraid!

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.

@absolutejam absolutejam changed the title Record optional fields Allow Record Option fields to be... optional Sep 4, 2020
@Happypig375
Copy link
Contributor

#617

@absolutejam
Copy link
Author

absolutejam commented Sep 4, 2020

Sorry! I did an awful job at looking 🤦

I must say, I only want to cover the Option case, not defaults. I think that's too big of a concern for this issue.

@woojamon
Copy link

woojamon commented Sep 5, 2020

type SomeConfig =
    {
        Name: string
        Description: string option
        Tags: string list option
    }

type SomeOtherConfig =
    {
        Name: string
        Tags: string list option
        Foo: string option
    }

// What should this be?
let x = { Name = "James"; Tags = [ "a"; "b" ] }

@Swoorup
Copy link

Swoorup commented Sep 5, 2020

I would like this change but not as a language feature, but rather a library. There are different mechanisms to achieve it, either macros, type provider consuming types.

@absolutejam
Copy link
Author

absolutejam commented Sep 5, 2020

type SomeConfig =
    {
        Name: string
        Description: string option
        Tags: string list option
    }

type SomeOtherConfig =
    {
        Name: string
        Tags: string list option
        Foo: string option
    }

// What should this be?
let x = { Name = "James"; Tags = [ "a"; "b" ] }

Just seems like any other case where the compiler can't infer, and you have to add an annotation.

let x : SomeConfig = { Name = "James"; Tags = [ "a"; "b" ] }
let x = { SomeConfig.Name = "James"; Tags = [ "a"; "b" ] }

@Happypig375
Copy link
Contributor

type SomeConfig =
    {
        Name: string
        Description: string option
        Tags: string list option
    }

type SomeOtherConfig =
    {
        Name: string
        Tags: string list option
        Foo: string option
    }

// What should this be?
let x = { Name = "James"; Tags = [ "a"; "b" ] }

The same as

type SomeConfig = { Name: string }
type SomeOtherConfig = { Name: string }

// What should this be?
let x = { Name = "James" }
do System.Console.WriteLine(x.GetType())

which is SomeOtherConfig.
https://sharplab.io/#v2:DYLgZgzgNALiBOBXAdgHxgTwA4FMAEAygPYC2OAwkcmAJYDmeAvHgN54ByAhmSHhDPBrIGAXwCwAKEy5CpHAHkYACxzxK1ek1YduOXv0HC84iZID0ZvAHUlnGHyVFEwACZ5lNCHgBGOAPySwDj2AB5abFxkWgBEAFK6ENHGki5EhBj8OCQAdOoQREHZVoIwOAAyQjgAFCHZAOLBACrY1QCUrUA==

@njlr
Copy link

njlr commented Oct 2, 2020

I often find myself writing lots of code for "Zero" or "Default" types:

type Foo =
  {
    Foo : int
  }
  with
    static member Zero
      with get () = { Foo = 0 }

type Bar =
  {
    Bar : list int
  }
  with
    static member Zero
      with get () = { Bar = [] }

type Baz = 
  {
    Foo : Foo
    Bar : Bar
    Qux : string option
  }
  with
    static member Zero
      with get () = 
        { 
          Foo = Foo.Zero
          Bar = Bar.Zero
          Qux = None  
        }

Currently it feels like a limitation of F# that I must manually write out these static members.

However, I'm not sure what language feature could make this more convenient. I am not convinced that Option should have a special place in the compiler since there might be other types for which we might want to provide a default.

I think this is also related to type-class proposals, since Zero could be considered a trait of the type. Currently you cannot integrate LanguagePrimitives.GenericZero with an existing type (see StackOverflow).

@absolutejam
Copy link
Author

Yeah, I use a lot of Zero methods too, but they always involve using nearest approximation of the zero'd value (eg. 0 isn't really the same as an absent number, "" isn't the same as a non-present string), or you make your record use an Option type but then you're changing the semantics of the record.

While Option is simple to implement from scratch, I do feel like it's a 'blessed' type because it's essentially F#'s equivalent of a nullable type.

I've been implementing a DSL for configuration of different kinds (which renders out to json), such as Kuberenetes resources, and I've found the best methods for a user-friendly API are:

  • static methods, because they allow optional parameters (eg. Thing.create(foo = "foo", bar = "bar"), and then I construct the record within the method
  • nested computation expressions and custom Yields to build a DSL, again leveraging a Zero'd version that's built upon

I just think it'd be a whole lot simpler if we could make fields optional like a static method.

@MaxWilson
Copy link

You don't need a computation expression here, just a regular static method leveraging named optional arguments.

type SomeConfig =
    {
        Name: string
        Description: string option
        Tags: string list option
    }
    with static member Create(Name, ?Description, ?Tags) = { Name = Name; Description = Description; Tags = Tags }
let config1 = SomeConfig.Create(Name="Config1")
let config2 = SomeConfig.Create(Name="Config2", Description="Emergency Mode", Tags=["A"; "D"; "F"])

I think you could probably even have Myriad generate the boilerplate Create method for you.

(Hat tip to @voronoipotato)

@absolutejam
Copy link
Author

absolutejam commented Oct 19, 2020

I totally agree that's an option - and I leverage this as an alternate - but the computation expression DSL I'm leveraging in my library is clearer.

(This idea was taken pretty much wholesale from https://github.com/UnoSD/Pulumi.FSharp.Extensions)

Take the below:

    prometheusRule {
        metadata {
            name "elasticsearch-monitor"
        }
        spec {
            serviceMonitorSpec {
                jobLabel "elasticsearch"

                namespaceSelector {
                    matchNames [ "elasticsearch" ]
                }

                endpoints {
                    // CE's also provide some nice behaviour via `yield`. In this case I'm 'inheriting' from a default
                    Config.defaultEndpoints 

                    port "http-metrics"

                    // Implicitly added to a list
                    metricRelabeling {
                        action Replace
                        sourceLabels [ "host" ]
                        regex "(.*)"
                        targetLabel "pod_ip"
                    }

                     // Again, using `yield` and custom functions I can create boilerplate-removing helpers
                     // (These do the same as the above `metricRelabling` block)
                    dropLabel "pod"
                    dropLabel "endpoint"
                }
            }
        }
    }

vs.

    // I know this could be streamlined a little
    PrometheusRule.create (
        metadata = Metadata.create (
            name = "elasticsearch-monitor"
        ),
        spec = Spec.create (
            ServiceMonitorSpec.create (
                jobLabel = "elasticsearch",

                namespaceSelector = NamespaceSelector.create (
                    matchNames = [ "elasticsearch" ]
                ),

                endpoints = Endpoints.create (
                    port = "http-metrics"
                    metricRelabelings = [
                        MetricRelabeling.create (
                            action = Replace,
                            sourceLabels = [ "host" ],
                            regex = "(.*)",
                            targetLabel = "pod_ip"
                        )
                    ]
                    /* More stuff */
                )
            )
        )
    )

I realise it might not look too different, but the idea is a clear DSL that allows you to build complicated, nested records
(Kubernetes manifests in my example), and in my testing the former is more approachable and readable when written as above. Plus, it's more flexible due to a bit of magic with overloading yield, for example I have omitted a field in the record that's a list and allow the children to be yielded directly and they're merged into a list implicitly.

I know we've diverged from the topic at hand a little, but if you squint hard enough at the first example, you can see it looks more like a record than the static methods, and I think it'd be nice to mirror that kind of structure a little with the record literal syntax - sure it means you don't get all of the yield overloading, but also means a whole lot less work for an underlying library.

Now, if #920 wasn't an issue, I'd be able to leverage custom operations instead of functions (to limit their scope).

@dsyme
Copy link
Collaborator

dsyme commented Jun 14, 2022

Closing as #617 covers this

@dsyme dsyme closed this as completed Jun 14, 2022
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

7 participants