Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

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

[Proposal]: Record Constraints #4453

Closed
1 of 4 tasks
Randy-Buchholz opened this issue Feb 21, 2021 · 2 comments
Closed
1 of 4 tasks

[Proposal]: Record Constraints #4453

Randy-Buchholz opened this issue Feb 21, 2021 · 2 comments

Comments

@Randy-Buchholz
Copy link

Record Constraints

  • Proposed
  • Prototype: Not Started
  • Implementation: Not Started
  • Specification: Not Started

Summary

This feature provides the ability to declare constraints on record elements using the shorthand syntax. These constraints get incorporated into the record definition.

The proposed syntax is :
record sample(int Foo = Constraint());
or
record sample(int Foo = IntConstraints.IsPositive());

Where Constraint is an operation that accepts a single parameter of the target type and returns a value of the target type (int here.)
Where IntConstraints is a class containing reusable rules that follow the same restrictions.

Motivation

The record is a useful addition to the language and provides a starting point for many concepts like DataDomains, ValueObjects, Structures (in the OOAD sense), and Records (in the business sense). What differentiates record from these is that record is a set of values, and the others often require a set of "Valid Values". You couldn't trust writing a record into a ledger without first checking the validity of the data. If you use shorthand syntax there is no way to incorporate these checks into the setters, where they belong. The code grows and becomes more complex by having to create external checking processes and objects. In cases like DataDomains and Records, these constraints are not secondary to the object, but part of it's core identity. These constraints need to be "baked-in".

While this can be achieved using the longhand syntax, that unnecessarily loses some of the charm of record.
This proposal provides a method for declaring and incorporating constraints/data rules to individual elements of the shorthand definition, in a simple, non-breaking way.

Detailed design

Attempting to use the proposed syntax now produces a "compile time constant" error.
But records aren't really compile time items. They are more models for a preprocessor that will generate a compile time classifier.
As such, they don't need to conform to all compiler conditions, only the generated classifier does.

This feature can be implemented with one change and an addition to the code generator.

First, eliminate the "compile time constant" check on parameter defaults for records so it doesn't complain about the default being an operation. If a record is a pre-compile model, this restriction isn't necessary.
Second, when generating the record code, move the call to the constraint operation out of the signature and into the appropriate setter.

The signature now meets the compile criteria, and element level data constraints are in the setters where they belong.

Neither is necessarily breaking. Removing the compile time check is broadening, and the other part just adds an independent step to the generation process.

Any "callable" construct can be used for constraints as long as it accepts a single parameter of the target type and returns a single value of that type (optionally null if the target type is nullable). It should normally depend only on the value provided when a record is created. Though design decisions could relax this in some cases. Because the constraint will get pre-processed, and must follow this format, the constraint doesn't need to show the parameter. It is added in the generation process. This helps keep the syntax compact. Constraints must handle all conditions (applying defaults or throwing exceptions when necessary).

Constraints can be declared externally and shared in many ways. They may also be declared internally.

public record Sample(string category = ValidCategory()){
    string ValidCategory(string input){ 
       return new[]{"A", "B"}.Contains(input) ? return input :  throw new Exception("Invalid Category");
    }
}

Drawbacks

Alternatives

Unresolved questions

Design meetings

@HaloFour
Copy link
Contributor

Not sure what this has to do with records specifically. Looks like a general purpose argument validation syntax, something akin to method contracts. IMO the suggested syntax looks too much like a default argument and might clash with any potential future enhancements around const expressions.

@konrad-jamrozik
Copy link

konrad-jamrozik commented Feb 21, 2021

Being unable to put constraints (invariants) on my records is currently a major pain point for me.

One of my use cases: I am reading data from an API of an external service, e.g. Azure DevOps - Page Stats - Get. I want to capture the data in a record type that enforces invariants, like "day stats for given page have each day at most once" and "day stat for given day, if present, has count of at least 1".

Currently I opted to use plain class, so I can put the invariants checks in the primary ctor. Example of what I mean:

public class WikiStats {
  
    public DataWithInvariants(IEnumerable<WikiPageStats> data)
    {
       // invariant checks here

       Value = data.ToArray();
    }
    
    public WikiPageStats[] Value { get; }
}

Question 1

What about maintaining invariants when processing the record data, e.g. with LINQ? Even with this proposal implemented I will have to access the underlying data via some member if I want to do some processing, like e.g.

WikiStats postprocessedWikiStats = new WikiStats(wikiStats.Value.Select(Process))

but ideally I would like to do the following:

WikiStats postprocessedwikiStats = wikiStats.Select(Process) 

The last snippet magically converts to WikiStats record to maintain invariants enforcement without the need of explicit ctor calls.

Of course I could implement my own Select and other LINQ methods, but that's a lot of boilerplate code. Maybe the way to go here is to use source generators introduced in C# 9?

Question 2

What about different error modes for the constraint/invariant violations? I can easily see the invariants being checked:

  • as part of normal system validation, to validate external inputs coming to the system.
  • as internal code logic assertion checks, e.g. if some domain model pure function sliced and diced the data with LINQ.

These two possibly should be treated differently. Have something akin to ArgumentException upon first record creation when the data first enters the system, but later on throw InvalidOperationException or similar, when the data is processed by the internal, pure business logic. It should probably be possible to avoid compiling in the "assertion mode" checks, to avoid performance hit.

Question 3

What about the alternative of allowing to declare the primary record constructor body? As seen in the Primary constructors in C# 10 proposal. Seems to me that would at least as powerful solution, and possibly simpler to implement, as well as conceptually?

@dotnet dotnet locked and limited conversation to collaborators Aug 17, 2021
@333fred 333fred closed this as completed Aug 17, 2021

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants