[Proposal]: Record Constraints #5075
Replies: 7 comments 1 reply
-
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. |
Beta Was this translation helpful? Give feedback.
-
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; }
} There is also a related StackOverflow question: How do I define additional initialization logic for the positional record? Question 1What 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 Of course I could implement my own Question 2What about different error modes for the constraint/invariant violations? I can easily see the invariants being checked:
These two possibly should be treated differently. Have something akin to Question 3What 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? |
Beta Was this translation helpful? Give feedback.
-
I've been looking into how to enforce valid state as well and right now as records don't allow any way to enforce invariants with the For example here is some literature: C# and F# approaches to illegal state
So after reading both these articles it's shown that right now records have no way of making invalid state impossible via the type system. Class has to be used to achieve this which then means all the nice equality/comparison functionality of records is lost and has to be manually implemented. A less than ideal situation all round. |
Beta Was this translation helpful? Give feedback.
-
A validated, immutable type is something I've longed for in C# for a long time! I've been working on a source generator and analyser that goes some way towards this. It's called Vogen (Value Object Generator) The goal was to have the same performance when using these 'Value Objects' (invariants) as when using the primitive directly. Here's an example of treating a 'customer ID' as something that's strongly typed rather than just using a primitive [ValueObject(typeof(int))]
public partial struct CustomerId {
// optional
private static Validation Validate(int value) => value > 0
? Validation.Ok
: Validation.Invalid("Customer IDs must be a positive number.");
} Usage is: var id1 = CustomerId.From(123);
// error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.
CustomerId c = default;
// error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.
var c2 = default(CustomerId); ... and if we try to circumvent validation by adding other constructors, we get: [ValueObject(typeof(int))]
public partial struct CustomerId {
// Vogen already generates this as a private constructor:
// error CS0111: Type 'CustomerId' already defines a member called 'CustomerId' with the same parameter type
public CustomerId() { }
// error VOG008: Cannot have user defined constructors, please use the From method for creation.
public CustomerId(int value) { }
} The code generated is very similar to what's produced by a I have no idea how this would work or if its feasible, but I would like something like this in the C#: public invariant struct CustomerId(int Value) {
// this must exist and the name of the argument must match
// a name in primary constructor
private static Validation Validate(int Value) => Value > 0 ? Validation.Ok : Validation.Invalid("must be greater than zero");
} |
Beta Was this translation helpful? Give feedback.
-
Maybe an public record struct CustomerId(invariant int Value)
{
// the argument name and type must match an argument name and type from the primary constructor
private static void Validate(int Value) => Invariant.Ensure(Value > 0);
// compiler error: CS4242 - No matching invariant in primary constructor named Oops
private static void Validate(string Oops) => Invariant.Ensure(Value > 0);
} The |
Beta Was this translation helpful? Give feedback.
-
I've taken a containers approach to records. As they are now, records have some nice features, but they are still "in the wild". They lack things like constraints and immutability - controls. They are good data containers, but they need some help. I wrap my records in other records - I create a record for the "Business Record" and create inner records for state management and communication. You can have several variants of a record; some can represent record state. There is always one state exposed publicly - a valid business record (if available). There is no public Basically, I build a little factory that buffers record data and makes a valid record available if it has valid data. Records are great when you know you have a good one, but for now the only way to ensure that is control all creation. I use things based on this idea:
I have to make a lot of things public, but I can get a valid business record with
////////////////////////////// The best use I've found for record right now is for Structures (in the OO sense, not
With proper care and feeding, records work pretty well.
|
Beta Was this translation helpful? Give feedback.
-
What about just using the where syntax to add what is effectively a check constraint on the record, E.G:
Idea being it generates additional check on construction after all properties have been assigned, to verify the bool expression evaluates to true. If false, it could just throw ArgumentException("check failed: 0 <= Row && Row < 16 && IsValidCol()"), or alternatively, allow some attribute to define a more localized string should that be important. In the example above, I imagine there could be variations that don't need to use any defined methods which could simplify syntax for many use cases:
|
Beta Was this translation helpful? Give feedback.
-
Record Constraints
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 differentiatesrecord
from these is thatrecord
is a set of values, and the others often require a set of "Valid Values". You couldn't trust writing arecord
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.
Drawbacks
Alternatives
Unresolved questions
Design meetings
Beta Was this translation helpful? Give feedback.
All reactions