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

Infer record names from types #1034

Open
5 tasks done
Happypig375 opened this issue Jun 15, 2021 · 9 comments
Open
5 tasks done

Infer record names from types #1034

Happypig375 opened this issue Jun 15, 2021 · 9 comments

Comments

@Happypig375
Copy link
Contributor

Happypig375 commented Jun 15, 2021

Infer record names from types

F# is a great language for domain-driven design with single-case unions, DUs for "or" types, and records for "and" types. Some may even say that F# files containing domain types only can be read by non-programmers easily!
(Extracted from https://github.com/matthewcrews/ddd-with-fsharp/blob/master/Examples/Domain4.fsx)

type ProfitCategory = 
    | Cat1
    | Cat2
    | Cat3
type ItemQuantity = ItemQuantity of float
type InventoryId = InventoryId of string
type UnitCost = UnitCost of decimal
type SalesRate = SalesRate of float
type StockItem = {
    InventoryId : InventoryId
    UnitCost : UnitCost
    SalesRate : SalesRate
    ProfitCategory : ProfitCategory
}
type DaysOfInventory = DaysOfInventory of float

But there are lots of duplication that hinders this objective! Wouldn't it be nice if we can write:

type ProfitCategory = Cat1 | Cat2 | Cat3
type ItemQuantity of float
type InventoryId of string
type UnitCost of decimal
type SalesRate of float
type StockItem = { InventoryId; UnitCost; SalesRate; ProfitCategory }
type DaysOfInventory of float

In essence, we reduced

type DomainType1 = DomainType1 of WrappedType1
type DomainType2 = DomainType2 of WrappedType2
type DomainType3 = DomainType3 of WrappedType3
type OrType = DomainType1 of DomainType1 | DomainType2 of DomainType2 | DomainType3 of DomainType3
type AndType = { DomainType1 : DomainType1; DomainType2 : DomainType2; DomainType3 : DomainType3 }

to

type DomainType1 of WrappedType1
type DomainType2 of WrappedType2
type DomainType3 of WrappedType3
type OrType = (DomainType1 | DomainType2 | DomainType3)
type AndType = { DomainType1; DomainType2; DomainType3 }

. The deduplication for single-case unions belong to #727, the deduplication for "or" types belong to #538, and here I'll propose the deduplication of "and" types.

A record or anonymous record written as

type Record = { Type1; Type2; Type3 }

will be interpreted as

type Record = { Type1 : Type1; Type2 : Type2; Type3 : Type3 }

where the constituent names are interpreted as types, highlighted as types, and the names will be inferred from the types.

When one of the types is a generic specialization, the name in source will be used:

type Shelf = { List<Item>; ShelfCategory }
type Warehouse = { Shelf list }
type Shelf = { ``List<Item>`` : List<Item>; ShelfCategory }
type Warehouse = { ``Shelf list`` : Shelf list }

However, having generic type parameters inside a type without a corresponding field label is not allowed.

type Shelf<'T> = { List<'T>; ShelfCategory } // Not allowed!
type Shelf<'T> = { Items : List<'T>; ShelfCategory } // Must be used

The alternative is to infer a name of List<'T> which is inconsistent with the type when specialized.

When dot-access notation is used, only the last part is used as the name.

type OrderTaken = { OrderTaking.Domain.Order; OrderTaking.Domain.OrderTaker }
type OrderTaken = { Order : OrderTaking.Domain.Order; OrderTaker : OrderTaking.Domain.OrderTaker }

When two names collide, error.

type InvalidDomainType = { OrderTaking.Domain.Order; Shipping.Domain.Order } // Error: Duplicate name "Order"

The duplicate name error also applies to:

type ``Shelf list`` = unit
type Warehouse = { Shelf list; ``Shelf list`` }

Pros and Cons

The advantages of making this adjustment to F# are

  1. Conciseness
  2. Readability to non-programmers like business analysts
  3. Correctness w.r.t. domain design without duplicate names like in current code

The disadvantage of making this adjustment to F# is that this introduces additional rules and syntax to learn. However, #653 is approved and has the same disadvantageous properties but for record construction.

Extra information

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

Related suggestions:
#727 and #538 - Deduplication for single-case unions and "or" types

#653 - Infer record labels from expressions

This proposal has an analogy for record creation as shown in that proposal. However, it uses name inference via nameof, but nameof(seq<int>) produces seq which would not be desirable here. Even with nameof(int seq) being an error currently, the only consistent output for it would be also seq as shown in #953. Moreover, it cannot purely rely on nameof name lookup either, because it wouldn't make sense to have local bindings starting with uppercase letters just to infer names, therefore

let a = 1
let b = {| B = 2 |}
{| a; b.B |}

would result in a value of type {| A : int; B : int |}, which makes it more complex than just a nameof lookup, like seen in this proposal.

#600 - Intersection types

Intersection types provide an alternate way to solve this problem. However, they not only take a huge implementation effort, but also:

  • Rely on downcasts for element extraction, so IntelliSense can't help with it, compared to property extraction using the dot-notation with records.
  • Fit into parameters taking one of its constituent types via implicit downcasting with Auto-upcast values when type information is available #849. This means that a domain AND type can magically fit into a constituent domain type. This is quite a jump in logic especially if the AND type is aliased as frequently done with normal records.
  • Will not have effective interoperability with C#. Granted deduplicating "or" types also do this, but the loss of type safety is small compared to having intersection types without C# cooperation.
  • Include all exposed members and extension members of their constituent types. "And" types are different from their constituent elements! They represent different things in the domain. Mixing members like this not only conflates domain objects, but also produces IntelliSense pollution.

#747 - Infer record field types from names

That proposal which bears syntactical resemblance to this proposal was shot down quickly after being posted. This proposal is different from it because:

  • That is about making presumably new single-case DUs magically just by specifying names in a record. That is too implicit with type declarations without the type keyword, and conflates name deduplication with primitive type avoidance.
  • Instead of being symmetric with imply record labels from expressions #653 where names are inferred from types, new types are inferred from names. This proposal infers names from types instead, being more coherent with imply record labels from expressions #653.

Therefore, that proposal cannot be used to justify rejecting this proposal.

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

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.

@dsyme
Copy link
Collaborator

dsyme commented Jun 17, 2021

Of the two different suggestions here, I find

    type ItemQuantity of float

more interesting. It's fairly simple and straight-forward and in principle it's not so bad to have a short cut for single case DUs.

However we really need to look a this issue holistically and doing so is quite a tricky issue for F#, where any move outside the existing status quo just risks "yet another way" of doing things which may be worse than the problem being solved.

The huge downside is that it really gives this alternative syntax for record-like declarations without people realising that it's actually a discriminated union. The user sees:

    type ItemQuantity of SomeValue: float * SomeValue2: float

and they say "boy, that looks like a record to me". Because, of course the distinction between records and single-case DUs is somewhat artificial, though in practice they have very different properties across the language design. So we have to be really, really careful here.

An alternative more radical direction is to have alternative, new syntax for record-like things and revise everything else accordingly. For example either

    type ItemQuantity of SomeValue: float * SomeValue2: float

or more radical shift:

    type ItemQuantity(SomeValue: float, SomeValue2: float)

Anything in this direction has major ramifications with many questions to be answered

  • Would properties be available for the first itemQuantity.SomeValue?
  • Would these be records in the FSharp.Reflection API?
  • Would the creation syntax change ItemQuantity(SomeValue=3.0, SomeValue3=4.0).
  • Would these be compatible with existing F# records, C# records etc?
  • Would the { A=1; B=2 } syntax suddenly be old-fashioned?

As an aside, the syntax type ItemQuantity(SomeValue: float, SomeValue2: float) would also seem to eventually imply a revision of the entire signature declaration syntax, e.g.

// Possible syntax revision for signatures
module M =
    val someFunction(x:int, y:int): int

type C(x: int, y: int) =
    member P: int
    member Method(v: int, u:int) : int

I'm actually not opposed to a revision of the signature syntax along these lines. But I'm pointing out there are potential ramifications right across the language design. F# would certainly still be F# with these revisions, but it trends towards a considerable revision that needs to be thought through.

@auduchinok
Copy link

auduchinok commented Jun 17, 2021

type ItemQuantity(SomeValue: float, SomeValue2: float)

Another question is what would be implications of these parameters looking like parameters in a object type implicit constructor? If they're going to be accessible like record fields, then are we going to change it for existing object types constructor parameters? This would be quite a big breaking change. And if these two same-looking syntaxes are considered different, it might be difficult to distinguish them, especially for newcomers.

Another question is what naming conventions should these parameters use? Record fields are PascalCase and constructor parameters, like other simple patterns, are camelCase.

@ShalokShalom
Copy link

ShalokShalom commented Jun 17, 2021

I think its in line with F#'s type system to use type inference by default and declare a specific one, when suitable.

I always found it conflicting with this strategy, to specify types manually in record types.

Particularly when I want my code so type safe as possible.

I see so many examples online, where all the record types and discriminated unions are strings and integers.

Do we have 73 strings and 283 integers in our code?

Or are these actually nearly all unique types.

This proposal suggests the usage of inferred types, who are unique and by that, more type safe and more expressive in type declarations.

@dsyme
Copy link
Collaborator

dsyme commented Jun 17, 2021

Another question is what would be implications of these parameters looking like parameters in a object type implicit constructor? If they're going to be accessible like record fields, then are we going to change it for existing object types constructor parameters? This would be quite a big breaking change. And if these two same-looking syntaxes are considered different, it might be difficult to distinguish them, especially for newcomers.

Yes, exactly.

Another question is what naming conventions should these parameters use? Record fields are PascalCase and constructor parameters, like other simple patterns, are camelCase.

Right, all these and other questions would need to be answered.

@charlesroddie
Copy link

charlesroddie commented Jul 20, 2021

this alternative syntax for record-like declarations without people realising that it's actually a discriminated union

People are using single case DUs because of a syntactic preference rather than specifically intending for discriminated unions. The language itself should use language structures (in the literal sense) properly and if there is any syntactic sugar for haskell newtypes, it should avoid creating DUs with only one case and with conflated case name and type name, and instead create records.

https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1073-record-constructors.md would create the syntax:

[<Struct; RequireQualifiedAccess>] // these annotations not strictly necessary
type ItemQuantity = { Value:float }
let x = ItemQuantity(1.)

This is very similar to and captures the benefits of

[<Struct>] // needs this for equality semantics for consistency with class syntax
type ItemQuantity(Value: float)
let x = ItemQuantity(1.)

That said, a class syntax which exposes primary constructor inputs as properties would be clean:

[<ExposeConstructorInputs>]
type V2(X:float, Y:float) =
    member EuclideanNorm = sqrt(X*Y + Y*Y)
let x = V2(2.,3.).X

@dsyme
Copy link
Collaborator

dsyme commented Jul 20, 2021

Possibly:

type V2(val X:float, val Y:float) =
    member EuclideanNorm = sqrt(X*Y + Y*Y)
let x = V2(2.,3.).X

I'd love to see a prototype of this

@dsyme
Copy link
Collaborator

dsyme commented Jul 21, 2021

Also

type V2(val mutable X:float, val mutable Y:float) =
    member EuclideanNorm = sqrt(X*Y + Y*Y)
let x = V2(2.,3.).X

and

type V2(internal val X:float, internal val Y:float) =
    member EuclideanNorm = sqrt(X*Y + Y*Y)
let x = V2(2.,3.).X

There would also be a question of whether attributes on that target the things existence as a parameter, or backing field, or property

@Happypig375
Copy link
Contributor Author

Hmmm... Those still need the field names, which this proposal aimed to have them inferred.

@Happypig375
Copy link
Contributor Author

Another question is what naming conventions should these parameters use? Record fields are PascalCase and constructor parameters, like other simple patterns, are camelCase.

Yes, any cased initial letter can be converted automatically, having PascalCase for records and camelCase for parameters. Errors can be raised on collision.

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

No branches or pull requests

6 participants