Models and EquatableArray
An incremental generator only skips work when a pipeline step’s output compares equal to the previous run. Roslyn symbols, syntax nodes and Location values do not compare by value, and holding one in a pipeline value defeats caching entirely. Haitch.Roslyn’s models are plain records that capture what a generator needs and never hold symbols, syntax or Location.
EquatableArray<T>
Section titled “EquatableArray<T>”EquatableArray<T> is a value-equal array wrapper used for every collection inside a model. T must implement IEquatable<T>?, so nullable reference types are allowed and EquatableArray<string?> works.
using Haitch.Roslyn.Types;
EquatableArray<string> names = new[] { "a", "b" }.ToEquatableArray();
var count = names.Count; // 2var first = names[0]; // "a"var isEmpty = names.IsEmpty; // falseReadOnlySpan<string> span = names.AsSpan();
var equal = names == new[] { "a", "b" }.ToEquatableArray(); // true, element-wiseToEquatableArray() is available on T[], ImmutableArray<T>, IEnumerable<T> and EquatableArray<T> itself. The last is the identity: it returns the same array instead of boxing and copying through the IEnumerable<T> overload. default(EquatableArray<T>) is a valid empty array, equal to any other empty one.
EquatableArray<T> implements IReadOnlyList<T>, so it can be passed to any API that takes one. It is also a collection-expression target (it carries a [CollectionBuilder] attribute), so a literal list needs no helper call:
EquatableArray<string> names = ["a", "b"];EquatableArray<string> none = []; // defaultEquatableArray<string> more = [.. names, "c"];An empty collection expression yields default.
The model types
Section titled “The model types”| Model | Captures |
|---|---|
TypeRef | A type reference: fully qualified name, nullable annotation, special type, type kind, value-type flag. TypeRef.From(ITypeSymbol). |
AttributeModel | An attribute’s type, metadata name, constructor arguments and named arguments. AttributeModel.From(AttributeData) returns null for an unresolved attribute. See Reading attribute arguments. |
ConstantValue | A constant: null, primitive, string, enum, type or array. Built with ForNull, ForPrimitive, ForString, ForEnum, ForType, ForArray, and read with typed TryGet... accessors. |
WellKnownAttributes | Ready-made AttributeModels: GeneratedCode(tool, version) and EditorBrowsableNever. See Attributes, new types and statements. |
TypeModel | A class, record, struct, record struct, interface or C# 15 union declaration. |
NewTypeModel | A brand-new, non-partial type to emit, as opposed to an existing one to extend. See Attributes, new types and statements. |
ContainingTypeModel | One enclosing type of a nested type. |
MethodModel, PropertyModel, FieldModel, EventModel, ParameterModel, TypeParameterModel | Members and their parts. Each has a From(...) factory over the matching symbol. |
SyntaxInfo | Syntax facts: IsPartial, AreContainingTypesPartial and a LocationInfo?. SyntaxInfo.From(TypeDeclarationSyntax). |
BuildProperties | A set of MSBuild properties read together. See Build properties. |
AdditionalFileModel | An additional file’s path, text and requested metadata. See Additional files. |
TypeModel
Section titled “TypeModel”TypeModel model = TypeModel.From(typeSymbol);TypeModel withMembers = TypeModel.From(typeSymbol, includeMembers: true);TypeModel records the namespace (null for the global namespace), name, Kind (TypeDeclarationKind), accessibility, modifier flags (IsStatic, IsAbstract, IsSealed, IsReadOnly, IsRefLikeType, IsFileLocal), type parameters, containing types (outermost first), attributes, base types (see below), and the Fields, Properties, Methods and Events arrays and MemberNames.
Members are captured only when includeMembers: true. A model with members changes whenever any member is edited, so the member arrays are empty by default. Ask for members only when the generator really reads them.
TypeModel.From throws ArgumentException for enums, delegates, C# 15 extension blocks and other kinds it does not model. For an extension block, model the containing static class instead. Indexers are skipped when collecting members; calling PropertyModel.From on an indexer throws ArgumentException.
Both TypeModel.From and MethodModel.From take an optional trailing CancellationToken. It is checked per captured member, parameter and type parameter, and cancellation throws OperationCanceledException.
TypeModel model = TypeModel.From(typeSymbol, includeMembers: true, cancellationToken);Member names
Section titled “Member names”TypeModel.MemberNames is an EquatableArray<string> holding the distinct, ordinal-sorted name of every member the type declares, and it is empty unless includeMembers is true. Unlike the typed arrays it lists nested types, indexers, constructors, operators and compiler-made members, because a generated member can clash with any of them, such as a record’s synthesized ToString. Accessors appear as get_X and add_X, an indexer appears as this[], and an explicit interface implementation appears under its qualified name, such as System.IDisposable.Dispose.
bool taken = type.MemberNames.Contains("ToString");Base types, interfaces and events
Section titled “Base types, interfaces and events”BaseType, Interfaces and AllInterfaces are always populated, whether or not includeMembers is set.
BaseTypeis aTypeRef?: the base class, ornullwhen the type has none beyondobjectorSystem.ValueType, and for interfaces. An implicit base is never recorded, sonullmeans “no user-written base class”.Interfaceslists the interfaces the type declares directly, in Roslyn’s order.AllInterfaceslists every interface the type implements, including those inherited from its base class and base interfaces.
if (!type.AllInterfaces.Any(i => i.FullyQualifiedName == "global::System.IDisposable")){ // the generated partial declaration can add the interface itself}Caching. AllInterfaces feeds on the base types’ own interface lists. Adding or removing an interface on a base type changes it, and so changes the model and invalidates caches built on it. That is correct (the answer to “does this type implement X” changed), but it means a model with AllInterfaces is not cached against edits to its base types.
TypeModel.Events holds EventModels when includeMembers is true. An EventModel records Name, Type, Accessibility, IsStatic and IsFieldLike, plus IsAbstract, IsVirtual, IsOverride, IsSealed, ExplicitInterface, ExplicitInterfaceMemberName and Attributes. IsFieldLike is true for event EventHandler E; and false when the accessors are written out. IsPartial is true for a C# 14 partial event, which yields one EventModel built from the definition part; IsFieldLike is always false for it. For an event from metadata it is always false, because the two forms cannot be told apart there. Name is not unique when explicit implementations are present, so match on ExplicitInterface as well.
Unions and closed types
Section titled “Unions and closed types”A type is a union (Kind == TypeDeclarationKind.Union) when a declaring syntax uses the union keyword, or, for a type from metadata, when it carries System.Runtime.CompilerServices.UnionAttribute. Implementing IUnion alone does not make a struct a union. UnionCaseTypes lists the case types in declaration order and is empty for every other kind. A partial declaration of a union is written as partial union.
IsClosed is true for a closed class or record. It is read from the closed modifier in source, and for metadata types from the host compiler’s IsClosed property when it has one, so it is false on a compiler that predates C# 15. A closed type also reports IsAbstract; a partial declaration echoes neither modifier.
Members
Section titled “Members”TypeParameterModel.VarianceisVarianceKind.In,OutorNone.FieldModel.IsVolatileis true for avolatilefield.PropertyModel.ReturnRefKindisNone,ReforRefReadOnly, the same enumMethodModeluses.MethodModel.IsPartialistruefor both the definition and the implementation part of a partial method;IsPartialDefinitionistrueonly for the part without a body. The writer uses it to emitpartial. It is a positional parameter, so aMethodModelbuilt by hand must pass it.- Explicit interface implementations are included in
MethodsandPropertieswhenincludeMembersistrue.Nameis the unqualified member name, andExplicitInterface(aTypeRef?) andExplicitInterfaceMemberNameidentify the interface member; both arenullfor an ordinary member. Because of that,Nameis not unique: it can repeat across overloads, and an explicit implementation can share a name with a member of the type. Match onExplicitInterfaceas well asName. This also changesMethodModel.From: it used to return the qualified Roslyn name such asSystem.IDisposable.Dispose.
Two flags are worth knowing when you render a partial declaration: interfaces always report IsAbstract and structs always report IsSealed, even though neither keyword is ever written. The typed writer already accounts for this.
Reading attribute arguments
Section titled “Reading attribute arguments”An AttributeModel holds its arguments as ConstantValues. Reading one without a cast goes through the typed accessors, which return false on a mismatch and never throw.
AttributeModel? notify = item.Attributes.Find("Notify.NotifyAttribute");
if (notify is not null && notify.TryGetNamedArgument("Name", out var nameArgument) && nameArgument.TryGetString(out var name)){ // [Notify(Name = "Title")]}
var raise = notify is not null && notify.TryGetNamedArgument("Raise", out var raiseArgument) && raiseArgument.TryGetBoolean(out var value) ? value : true;On AttributeModel:
TryGetNamedArgument(string name, out ConstantValue value)matches the name exactly, case-sensitively.TryGetConstructorArgument(int index, out ConstantValue value)reads by position. Aparamsargument is one array value. An out-of-range index returnsfalse.MetadataNameis the attribute class’s metadata name in the formForAttributeWithMetadataNametakes (Ns.Outer+Inner,Ns.Foo`1). It isnullon a hand-built model.Find(fullyQualifiedMetadataName)is an extension onEquatableArray<AttributeModel>. It returns the first attribute whoseMetadataNameequals the argument ordinally, ornull. A hand-built model with anullMetadataNamenever matches.
On ConstantValue:
| Member | Succeeds when |
|---|---|
IsNull | The constant is a null. |
TryGetString(out string) | It is a string. |
TryGetBoolean, TryGetInt32, TryGetInt64, TryGetDouble | It is a primitive of exactly that type. A long argument does not satisfy TryGetInt32. |
TryGetEnum<TEnum>(out TEnum) | It is an enum constant with an integral underlying value. The underlying value is converted to TEnum; the enum’s identity is not checked. |
TryGetType(out TypeRef) | It is a typeof(...) constant. |
TryGetArray(out EquatableArray<ConstantValue>) | It is an array. |
TryGetStringArray(out EquatableArray<string?>) | It is an array whose elements are all strings or nulls. |
A null string argument is not a string: check IsNull for it, since TryGetString returns false.
Constructing models by hand
Section titled “Constructing models by hand”The models are ordinary records, so a generator can build one directly, for example for a method it wants to emit. This is also how the walkthrough declares a ToString override.
var stringType = new TypeRef( "string", NullableAnnotation.NotAnnotated, SpecialType.System_String, TypeKind.Class, IsValueType: false);Keep models cache-safe
Section titled “Keep models cache-safe”- Put only models,
EquatableArray<T>and primitives in pipeline values. - Use
EquatableArray<T>, notT[],List<T>orImmutableArray<T>. - Pull
DiagnosticDescriptorvalues out intostatic readonlyfields. They have no value equality and must not travel through the pipeline.
The testing package can check all of this for you with AssertCacheable and CachingHazardWalker.