fsharp / fslang-suggestions

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

Infer record names from types #1034

Open Happypig375 opened 3 years ago

Happypig375 commented 3 years ago

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:

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:

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

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 3 years ago

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

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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

Happypig375 commented 3 years ago

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.