A 10 minute introduction to C# Attributes
C# Attributes are awesome! But since you clicked this post, something tells me you don’t agree (yet!). When I was a junior dev, not understanding Attributes, I simply dismissed them as something used by some frameworks.
However, now that I’ve used some and built some myself, attributes can really get me excited! You know what’s coming next, right? In this article, I’ll be introducing attributes, showing you how others use them, and what awesomeness you can create yourself with attributes. Enjoy!
C# attributes in a nutshell
Let’s start with the one thing that confused me the most: Attributes do NOT add behavior.You can use attributes to decorate all sorts of elements with custom metadata. None of this has any use until you add it.
The metadata added by Attributes can either be used during compile time with Source Generators or queried with reflection during runtime. This metadata can then be used by frameworks or your own generic code to decide how something should be handled.
Common attributes you might encounter are:
- informing the compiler an element is no longer in use
- tell the API framework what role a user needs to access an endpoint
- control how the debugger displays an object, property, or field
1class Person2{3[Obsolete("GetAge is deprecated, please use GetBirthDate instead.")]4public int GetAge()5{6// Omitted code for brevity7}89public DateTime GetBirthDate()10{11// Omitted code for brevity12}13}14
The most important thing to remember is that Attributes do not add behavior, but instead inform other features how they should behave.
My first custom attribute
Before we start exploring the awesomeness of C# attributes, let’s make a custom attribute ourselves. Definitely feel free to skip this section if this is not your first custom attribute.
Extending the
Creating a custom attribute is as simple as creating a class that extends the Attribute
class.
Positional parameters
Next, we can add positional parameters by adding a constructor to our attribute. Parameters can be made optional.
Optional Parameters
We can also add (optional) named parameters by adding a public property.
AttributeUsage
The last step is telling the compiler where our attribute can be used. We do this by adding the AttributeUsage
attribute to our class and defining the AttributeTargets
.
AllowMultiple and Inherited
We can also allow the attribute to be specified more than one time with the named parameter AllowMultiple
. And we can specify whether derived classes can inherit the attribute with the named parameter Inherited
1class WarningAttribute : Attribute2{34}5
1[Warning]2class ExpensiveCalculations3{45}6
Extending the
Creating a custom attribute is as simple as creating a class that extends the Attribute
class.
1class WarningAttribute : Attribute2{34}5
1[Warning]2class ExpensiveCalculations3{45}6
Positional parameters
Next, we can add positional parameters by adding a constructor to our attribute. Parameters can be made optional.
1class WarningAttribute : Attribute2{3private readonly string _message;4private readonly WarningType _type;56public WarningAttribute(string message, WarningType type = WarningType.Normal)7{8_message = message;9_type = type;10}11}12
1[Warning("Prefer 'CheapCalculations'")]2class ExpensiveCalculations3{45}6
Optional Parameters
We can also add (optional) named parameters by adding a public property.
1class WarningAttribute : Attribute2{3public string Author { get; set; }45private readonly string _message;6private readonly WarningType _type;78public WarningAttribute(string message, WarningType type = WarningType.Normal)9{10_message = message;11_type = type;12}13}14
1[Warning("Prefer 'CheapCalculations'", Author = "Lars Volkers")]2class ExpensiveCalculations3{45}6
AttributeUsage
The last step is telling the compiler where our attribute can be used. We do this by adding the AttributeUsage
attribute to our class and defining the AttributeTargets
.
1[AttributeUsage(AttributeTargets.Method | AttributeTargets.Field)]2class WarningAttribute : Attribute3{4public string Author { get; set; }56private readonly string _message;7private readonly WarningType _type;89public WarningAttribute(string message, WarningType type = WarningType.Normal)10{11_message = message;12_type = type;13}14}15
1class ExpensiveCalculations2{3[Warning("Prefer 'CheapCalculations.RecursiveLoop'", Author = "Lars Volkers")]4public int[] RecursiveLoop(int[] arr)5{6// Omitted code for brevity7}8}9
AllowMultiple and Inherited
We can also allow the attribute to be specified more than one time with the named parameter AllowMultiple
. And we can specify whether derived classes can inherit the attribute with the named parameter Inherited
1[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]2class WarningAttribute : Attribute3{4private readonly string _message;5private readonly WarningType _type;67public WarningAttribute(string message, WarningType type = WarningType.Normal)8{9_message = message;10_type = type;11}12}13
1[Warning("Prefer 'CheapCalculations'")]2[Warning("Class is resource intensive")]3class ExpensiveCalculations4{56}7
Tell frameworks and libraries what to do
The most common attributes are the ones that have been predefined by Microsoft for particular tasks and are documented in MSDN. Frameworks and libraries often use attributes to let users determine how a framework or library should behave.
When serializing objects to JSON with either Newtonsoft’s Json.NET or Microsoft’s System.Text.Json you can use attributes to inform how an object should be serialized:
We also slowly start to see the awesomeness of custom attributes when you extend the ValidationAttribute
. This way you can add your own attributes which can be used by the model validation. Let’s see how we actually do that:
This time we extend ValidationAttribute
instead of the default Attribute
. With ValidationAttribute
we can override the IsValid
for our own validation logic. Since our AlcoholRestrictionAttribute
only makes sense when we're validating an Order
, we check if we are actually validating an Order
object. Next, we check if the Customer
s age is larger than the minimum age in the Country
where the Order
is placed. If that's not the case, we return a user-friendly error message.
1[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]2public class AlcoholRestrictionAttribute : ValidationAttribute3{45protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)6{7if (value is Order order)8{9var restrictedAge = GetAgeRestrictionByCountry(order.Country);10Customer customer = order.Buyer;11if (customer.Age < restrictedAge)12{13return new ValidationResult($"We only sell alcohol above age {restrictedAge} for country {order.Country}");14}15}16}1718private int GetAgeRestrictionByCountry(string country)19{20return country switch21{22"USA" => 21,23"Netherlands" => 18,24// ...etc25_ => throw new ArgumentOutOfRangeException($"Value {country} for argument {nameof(country)} not supported")26};27}28}29
Custom attributes for fun and profit
Now that we’ve seen how to create our own attributes and how frameworks and libraries use attributes, let’s see how we can use them ourselves.
Accessing your attributes works the same, whether you’re using them during runtime or in your source generators. In both cases, we need to use Reflection to query the metadata on the types we’re working with.
Actually retrieving an attribute is as simple as:
1//...2Type orderType = typeof(Order);3Type attributeType = typeof(AlcoholRestrictionAttribute);4object myAttribute = Attribute.GetCustomAttribute (orderType, attributeType);5if(myAttribute is AlcoholRestrictionAttribute restrictionAttribute)6{7//Our attribute is found8}9//...
Now that we have our attribute instance we can use it like any other instance. The difficulty isn't how to retrieve our attribute. But depending on the AttributeTarget
of our custom attribute it might be a bit harder to get the right type through reflection. Below are some examples how to find different AttributeTarget
s.
12//[AttributeUsage(AttributeTargets.Class)]3var classAttribute = Attribute.GetCustomAttribute(classType, attributeType);45//[AttributeUsage(AttributeTargets.Constructor)]6var constructorTypes = classType.GetConstructors();7var constructorAttributes = constructorTypes8.Select(ctor => Attribute.GetCustomAttribute(ctor, attributeType));910//[AttributeUsage(AttributeTargets.Parameters)]11var methodType = classType.GetMethod("MyMethod");12var parameterTypes = methodType.GetParameters();13var parameterAttributes = parameterTypes14.Select(parm => Attribute.GetCustomAttribute(parm, attributeType));1516
But, why?
We’ve seen how to make our own attributes, how others use them, and how we can use them ourselves. But why would we want to use them?
Have you ever created several empty derived interfaces just to identify groups of types? This is where you should use attributes. Attributes are the preffered way to identify groups of types. They are also more flexible if you need complex criteria when grouping types.
1public class Dog : IOmnivore2{3void IAnimal.Eat(Food food)4{5// Omitted code for brevity6}7}89interface IAnimal10{11void Eat(Food food);12}1314interface IHerbivore : IAnimal { }15interface ICarnivore : IAnimal { }16interface IOmnivore : IAnimal { }17
Attributes are also awesome for when you need per-class customization in otherwise generic code. Let’s say you’re writing some custom code to generically map your DTOs to your domain models. Reflection to the rescue! But you don’t want both to have the exact named properties (or worse, use some weird naming conventions). This is where attributes really shine. With custom attributes, you can instruct your generic code on what properties should map to each other.
1public static class ApplyPropertyExtensions2{3//using our attribute in a generic extension method4public static void ApplyTo<T, U>(this T thisObject, U otherObject)5where T : class6where U : class7{8var thisType = typeof(T);9var otherType = typeof(U);1011var otherProperties = otherType.GetProperties();12foreach (var property in otherProperties)13{14var ourAttribute = Attribute.GetCustomAttribute(property, typeof(FromPropertyAttribute));15if (ourAttribute is not FromPropertyAttribute fromPropertyAttribute || fromPropertyAttribute.OtherType != thisType)16{17continue;// move to next property if types don't match or attribute is not found18}1920var thisProperty = thisType.GetProperty(fromPropertyAttribute.PropertyName);21if (thisProperty == null)22{23continue;// move to next property if property name is not found24}2526var propertyValue = thisProperty.GetValue(thisObject);27if (fromPropertyAttribute.Converter != null)28{29var converter = (IPropertyConverter)Activator.CreateInstance(fromPropertyAttribute.Converter); //create our converter30var convertedValue = converter.Convert(propertyValue);31property.SetValue(otherObject, convertedValue);32continue;33}34property.SetValue(otherObject, propertyValue);35}36}37}38
Finally, attributes are a great way to add opt-in functionality to your Source Generators. I’ll explore this option more in-depth in a future article. But for now, let’s just say you can combine the two examples above. Custom attributes can inform your Source Generator for which element it should run, while also allowing you to add extra information for how it should run.
Attributes, Generics, C#11
I hope you’re now just as excited about Attributes as I am!? However, C#11 makes attributes even more awesome by adding support for generics.
Previously, when you needed some —Type info— you had to use typeof
like in the previous example.
I don’t know about you, but I do not like the looks of that. The code not only feels convoluted, but we also miss out on any generic awesomeness.
Luckily, with C#11 we finally get support for generics. Allowing us to write our generic attributes as [MyCustomAttribute<SomeClass>]
instead of [MyCustomAttribute(typeof(SomeClass))
And I can already hear you think sarcastically; “wow, the syntax changed a little bit for the better…”
But that’s not everything, when using generics instead of a System.
parameter we can finally constrain the type passed to our attribute. Meaning, C#11 can help us go from:
1public class NotGenericAttribute : Attribute2{3public Type CustomConverter { get; }45private string GetMessage() =>6$"Converter should implement {nameof(IConverter)}, but it doesn't";78public NotGenericAttribute(Type customConverter)9{10if (customConverter.GetInterface(nameof(IConverter)) == null)11{12//this exception is only thrown at runtime13//there are no compiler errors when customConverter doesn't implement IConverter14throw new ArgumentException(GetMessage(), nameof(customConverter));15}16CustomConverter = customConverter;17}18}19
To:
1public class GenericAttribute<T> : Attribute2where T : IConverter3//compiler shows error when customConverter doesn't implement IConverter4{5public T CustomConverter { get; }67public GenericAttribute(T customConverter)8{9//no type checks needed10CustomConverter = customConverter;11}12}13
What do I think
You might have picked up a hint or two that I think attributes can be pretty awesome! And I really hope I convinced you as well.
Attributes allow me to write cleaner and smarter code that can be used in more than one situation. By adding declarative metadata where needed, I remove the necessity for complex conventions or opaque tricks.
I’m still finding new ways to use attributes. But I’m glad I took a deep dive into attributes after Sonar started screaming at me with:
Empty interfaces are usually used as a marker or a way to identify groups of types. The preferred way to achieve this is to use custom attributes.
Please come back to this article in the future. I plan on showing off Attributes together with Source generators. And I might add some cool snippets or examples if I encounter more uses for Attributes.
What to read next:
I really hope you enjoyed this article. If you did, you might want to check out some of these articles I've written on similar topics.
- Read Build AI-Powered Applications with Microsoft.Extensions.AI— 9 min read readBuild AI-Powered Applications with Microsoft.Extensions.AI
- Read .NET Aspire & Next.js: The Dev Experience You Were Missing— 7 min read read.NET Aspire & Next.js: The Dev Experience You Were Missing
- Read Improve Signalr and React Performance User Experience— 5 min read readImprove Signalr and React Performance User Experience