In this article, we are going to learn how generic attributes help us implement better custom attributes through a practical example.
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>]
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:
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 ofdynamic
string
forstring?
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.
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:
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.