Search code examples
c#genericsrecord

C# records, is there a way to property Name is a part of a record so I can use with expression?


I am writing a library similar to Fluent Validator that will allow object modification. For ordinary classes, it works without a problem but I try to code a similar approach with a record and stumble upon a big problem.

Rules are defined same as in Fluent Validator:

RuleFor(x => x.Name).ForceUppercaseFormat();

Method ForceUppercaseFormat() is defined in extension class that looks like this:

public static ImmutableLogicRuleBuilder<TModel,string> ForceUppercaseFormat<TModel>(this ImmutableLogicRuleBuilder<TModel,string> builder)
    where TModel : INamed
{
    builder.Apply(new StringToUpperCase(), (model, changedName) =>
    {
        model = model with { Name = changedName };
        return model;
    });

    return builder;
}

For reference

public interface INamed
{
    string Name { get; }
}

There are two problems. One is that Name does not have init. I did not add it at the beginning because I would reuse this interface for classes and records. I decided that I will focus on records so I added init. This did not solve anything. According to Rider TModel is not valid record so I cannot use with operator.

Is there a way to limit generic to work only for records?

As a workaround I could add two overloads for ForceUppercaseFormat() format with Action<TModel, TProp> and Func<TModel, TProp, TModel> as mutators for class and record, but this does not look nice and a lot of extra code.

// for class
RuleFor(x => x.Name).ForceUppercaseFormat((x, newName) => x.Name = newName);

// for records
RuleFor(x => x.Name).ForceUppercaseFormat((x, newName) => x with {Name = newName});

Solution

  • A with expression can be used with a record class, an anonymous type, or a value type.

    There is no generic constraint to require a record class or an anonymous type. The only way you can use with in a generic method is if the type is constrained to be a struct. (And even then, ReSharper used to have a problem with that, so I can only assume Rider did as well.)

    Probably the simplest option would be to define a mutation interface that all models would have to implement - eg:

    public interface INamed
    {
        string Name { get; }
    }
    
    public interface INamed<TModel> : INamed where TModel : INamed<TModel>
    {
        TModel WithName(string nameName);
    }
    
    public record Foo(string Name) : INamed<Foo>
    {
        public Foo WithName(string nameName) => this with { Name = newName };
    }
    

    You could then add this constraint to your method:

    public static ImmutableLogicRuleBuilder<TModel,string> ForceUppercaseFormat<TModel>(
        this ImmutableLogicRuleBuilder<TModel,string> builder)
        where TModel : INamed<TModel>
    {
        builder.Apply(new StringToUpperCase(), (model, changedName) =>
        {
            model = model.WithName(changedName);
            return model;
        });
    
        return builder;
    }