In this article, we are going to learn how generic attributes help us implement better custom attributes through a practical example.

To download the source code for this article, you can visit our GitHub repository.

While we go in-depth in this article with generic attributes, we do have introductory articles on Generics and Custom Attributes if these are new topics to you.

Implementing Generic Attributes the Old Way

Before C#11, to implement attributes in a type-agnostic way, we used to take advantage of System.Type class to pass the type information via the constructor parameter. With this approach, the parameter could be of any type and we had no control over it.

So, let’s spin up a C# console application, and create a vehicle validation attribute, that can accept a type that implements validation for vehicles:

[AttributeUsage(AttributeTargets.Class)]
public class VehicleValidatorAttribute : Attribute
{
    private Type _vehicleValidatorType;

    public VehicleValidatorAttribute(Type vehicleValidatorType)
    {
        _vehicleValidatorType = vehicleValidatorType;
    }

    public Type VehicleValidatorType => _vehicleValidatorType;
}

Here we accept the Type of the class that implements the validation as a constructor parameter and the VehicleValidatorType property is exposed to get the validator type. The issue with this type of implementation is that we can pass any type to the constructor.

Now, let’s assume that we use this attribute to create an instance of a VehicleValidatorType using reflection and call IsValid() method of IVehicleValidator interface:

public static IVehicleValidator<T>? GetVehicleValidator<T>() where T : class
{
    var modelType = typeof(T);

    var validatorAttr = modelType
        .GetCustomAttribute(typeof(VehicleValidatorAttribute)) as VehicleValidatorAttribute;

    if (validatorAttr is not null)
    {
        var validatorType = validatorAttr.VehicleValidatorType;
        var vehicleValidator = Activator.CreateInstance(validatorType) as IVehicleValidator<T>;

        return vehicleValidator;
    }

    return null;
}

If the passed-in vehicle validator type implements the IsValid() method of IVehicleValidator interface, all is well.

But what happens if we pass a type that doesn’t implement the IsValid() method? Yes, it will fail during runtime, trying to call the validation method on the null value returned.

Using C# 11 Generic Attributes

Let’s now use C# 11 generic attributes to implement the VehicleValidator attribute:

[AttributeUsage(AttributeTargets.Class)]
public class VehicleValidatorAttribute<T> : Attribute
    where T : class
{
}

Now, to get the type information, we can use the generic parameter. We don’t need to use unwanted properties and constructor parameters as we did earlier.

Advantages of Using Generic Attributes

Let’s have a look at the several benefits generic attributes bring.

C# Style Syntax

The attribute usage syntax is now more close to how C# syntax is. If we take the example of the VehicleValidator attribute, before C# 11 we would need to pass the type parameter using the typeof operator:

[VehicleValidator(typeof(CarValidator))]

Compared to this, we now have the syntax in line with C# generic style. It’s arguably cleaner and more understandable to C# developers:

[VehicleValidator<CarValidator>]

Wanna join Code Maze Team, help us produce more awesome .NET/C# content and get paid? >> JOIN US! <<

Type Restriction

The option to specify generic type constraints along with attributes prevents the unintended usage of types with the attributes. If we use types that don’t pass the generic constraint, we will get a compile-time error:

Type restriction on generic attributes

This is a major improvement to minimize the number of runtime errors with attributes.

Restrictions With Generic Attributes

It’s also important to keep in mind some of the restrictions with generic attributes as of C# 11.

Restriction on Type

Generic attributes restrict the usage of a few types as type parameters:

  • dynamic type
  • Nullable reference types like string?
  • Tuples using C# style syntax like (int x, int y)

The impermissible ones are the types that need metadata annotations. Although, we could use an alternative type:

  • object instead of dynamic
  • string for string?
  • ValueTuple<int, int> instead of (int x, int y)

Fully Closed Construction

When using a generic feature, the type we use needs to be fully specified and constructed. That means we cannot use the type parameter from the enclosing type if the enclosing type itself is generic:

public class GenericAttribute<T> : Attribute { }

public class GenericType<T>
{
    [GenericAttribute<T>] // Invalid. Compile time error.
    public string Method1() => default;

    [GenericAttribute<string>] // Valid
    public string Method2() => default;
}

We get a compile-time error when we use the type parameter from the class with the generic attribute of Method1. On the other hand, using an allowed type directly is valid.

Wanna join Code Maze Team, help us produce more awesome .NET/C# content and get paid? >> JOIN US! <<

A Practical Example of Generic Attribute

Let’s implement a generic vehicle validator that validates vehicles by some logic. We will build up from the VehicleValidator attribute we created earlier.

First, let’s create a generic interface to define a contract for our validators:

public interface IVehicleValidator<T>
    where T : class
{
    bool IsValid(T entity);
}

Secondly, let’s add the models for the Vehicles:

public class Car
{
    public bool IsConvertible { get; set; }
}

public class Truck
{
    public string? LoadCapacity { get; set; }
}

After that, we are going to define a validator for a specific vehicle. For instance, a CarValidator deriving from IVehicleValidator:

public class CarValidator : IVehicleValidator<Car>
{
    public bool IsValid(Car car) => car.IsConvertible;
}

Here we define the validation to be successful if the Car is a convertible type car.

In the next step, let’s modify the vehicle validation attribute we created earlier to specify our restrictions. We can do that based on the IVehicleValidator interface:

[AttributeUsage(AttributeTargets.Class)]
public class VehicleValidatorAttribute<T, TEntity> : Attribute
where T : class, IVehicleValidator<TEntity>
where TEntity : class
{
}

We specify that the vehicle entities must be a class and the validator should be an IVehicleValidator of a specific vehicle type like a Car. This is where we take advantage of generic attributes in C#11 to restrict types using constraints.

Finally, we need a ValidationHelper class that exposes a method to get the validator for the type passed in:

Wanna join Code Maze Team, help us produce more awesome .NET/C# content and get paid? >> JOIN US! <<
public static class ValidationHelper
{
    public static IVehicleValidator<T>? GetValidator<T>()
        where T : class
    {
        var modelType = typeof(T);

        var validatorAttr = modelType
            .GetCustomAttribute(typeof(VehicleValidatorAttribute<,>));

        if (validatorAttr is not null)
        {
            var validatorType = validatorAttr
                .GetType()
                .GetGenericArguments()
                .First();

            return Activator.CreateInstance(validatorType) as IVehicleValidator<T>;
        }

        return null;
    }
}

We use reflection code to get the model type like Car or Truck. Then, we find the attribute of the VehicleValidator type using that model. Afterward, we take the first generic type argument from the attribute for creating the vehicle validator instance.

To see our code in action, let’s fetch the validator for Car and perform the validation:

public static void Main()
{
    var carValidator = ValidationHelper.GetValidator<Car>();
    var car = new Car()
    {
        IsConvertible = true
    };

    Console.WriteLine(carValidator?.IsValid(car));
}

As we create a convertible Car, the validation will be successful and the console output will be true.

Conclusion

In this article, we’ve learned about generic attributes in C# along with the benefits and limitations. Also, we saw them in action with our hands-on exercise and how it helps enforce type-safe attribute implementations. Finally, we’ve used generic constraints to specify conditions for allowed types in a generic attribute.