Open sandboxFocusImprove this doc

Working with types

The Metalama type system represents C# types through the IType interface and its derived types. It's aligned with the C# type system and Roslyn but differs from System.Reflection.

Class diagram

classDiagram
      IType <|-- INamedType
      INamedType <|-- ITupleType
      IType <|-- ITypeParameter
      IType <|-- IArrayType
      IType <|-- IPointerType
      IType <|-- IFunctionPointerType
      IType <|-- IDynamicType
      INamedType o-- ITypeParameter
      ITupleType o-- ITupleElement

      class IType {
          TypeKind TypeKind
          SpecialType SpecialType
          IsReferenceType bool?
          IsNullable bool?
          Equals(...) bool
          IsConvertibleTo(...) bool
          MakeArrayType(int) IArrayType
          MakePointerType() IPointerType
          ToNullable() IType
          ToNonNullable() IType
      }

      class INamedType {
          ...
          TypeParameters IReadOnlyList~ITypeParameter~
          TypeArguments IReadOnlyList~IType~
          BaseType INamedType
          ImplementedInterfaces
          MakeGenericInstance(...)
      }

    class IArrayType {
        ElementType IType
        Rank int
    }

    class IPointerType {
        PointedAtType IType
    }

    class ITupleType {
        CreateCreateInstanceExpression(...)
    }

    class ITypeParameter {
        Name string
        Index int
        TypeConstraints IReadOnlyList~IType~
        TypeKindConstraint TypeKindConstraint
        AllowsRefStruct bool
        Variance VarianceKind
        IsConstraintNullable bool?
        HasDefaultConstructorConstraint bool
        TypeParameterKind TypeParameterKind
    }

    class ITupleElement {
        Index int
        Name string
        Type IType
        TupleElements IReadOnlyList~ITupleElement~
    }

    class TypeFactory {
        GetType(System.Reflection.Type) IType
        GetNamedType(System.Reflection.Type) INamedType
        CreateTupleType(...) ITupleType
    }

    TypeFactory --> IType
    TypeFactory --> INamedType
    TypeFactory --> ITupleType

Kinds of types

The type system in Metalama distinguishes between:

  • Named types (INamedType) - Classes, structs, interfaces, intrinsics like string or void, etc.
  • Tuple types (ITupleType) - Like (double X, double Y, double Z) or (int, string).
  • Array types (IArrayType) - Like int[] or string[,]
  • Pointer types (IPointerType) - Like int*
  • Type parameters (ITypeParameter) - Generic parameters like T in List<T>
  • Dynamic type (IDynamicType) - The dynamic type.
  • Function pointers (IFunctionPointerType) are not fully supported in Metalama.

Named types

A named type in Metalama is represented by the INamedType interface and corresponds to any type that has a name in C#: classes, structs, interfaces, enums, delegates, and records.

Named types are the fundamental building blocks of C# programs. Unlike other types in the type system (such as arrays, pointers, or type parameters), named types:

  • Have a fully qualified name (e.g., System.Collections.Generic.List<T>).
  • Can contain members (methods, properties, indexers, fields, events, constructors, extension blocks).
  • Can implement interfaces and inherit from base types.
  • Can have nested types.
  • Can be generic (with type parameters).

Tuple types, represented by the ITupleType interface, are also named types because ITupleType derives from INamedType.

Warning

Extension blocks (IExtensionBlock), despite implementing INamedType interface, are not types.

Examples of named types

// Classes
public class Customer;
public record Person(string Name, int Age);

// Structs
public struct Point;
public record struct Point( float X, float Y );

// Interfaces
public interface IRepository;

// Enums
public enum Status;

// Delegates
public delegate void EventHandler();

// Generic types
public class List<T>;

// Nested types
public class Customer
{
    public class Builder;
}

// Tuple types
(string Name, int Age)

Getting an IType object

There are several ways to get an IType instance from your compile-time code.

From typeof(.)

You can use the TypeFactory.GetType and TypeFactory.GetNamedType methods to map a System.Type to the corresponding IType or INamedType.

var stringType = TypeFactory.GetNamedType(typeof(string));
var stringArrayType = TypeFactory.GetType(typeof(string[]));
Warning

Metalama doesn't support the full System.Type API at compile time for types that represent run-time types. typeof expressions work with run-time types and return an opaque implementation of the System.Type abstract type, which doesn't allow you to use other features of the system reflection API.

From special types (intrinsics and other)

Some types are identified by a member of the SpecialType enum. Using the TypeFactory.GetType(SpecialType) method is often more compact and efficient than using typeof.

var stringType = TypeFactory.GetType( SpecialType.String );

From the current project

You can get any type of the current project by querying the ICompilation object.

Warning

For best performance, avoid enumerating all types of all namespaces. Instead, whenever possible, navigate the namespaces and select the desired type using the OfName method.

// From the compilation, in the context of a template
var myType = meta.Target.Compilation
    .GlobalNamespace
    .GetDescendant("My.Namespace")
    .Types
    .OfName( "MyClass" );

Generic types

Generic types in Metalama are represented by types that implement the IGeneric interface. Both INamedType and IMethod implement this interface.

Type parameters are represented by ITypeParameter. You can access them through the following collections:

  • IGeneric.TypeParameters expose the type parameters, i.e. T for an instance List<int> of the type definition List<T>.
  • IGeneric.TypeArguments expose the type arguments, i.e. the type bound to the arguments, i.e. int for an instance List<int> of the type definition List<T>.

Unlike MSIL, Metalama doesn't have a concept of "open" generic type with unbound type parameters. Type parameters are always bound to an argument. In generic type definitions, the type parameters are bound to themselves.

Consider the type List<T>, where T is a type parameter. In the generic type instance List<int>, T is the type parameter, int is the type argument, and the T parameter is bound to int. In the type definition List<T>, T is both the type parameter and the type argument because T is bound to itself.

The IGeneric interface exposes the IsCanonicalGenericInstance property, which returns true if all type parameters are bound to themselves.

Creating generic instances

Use MakeGenericInstance to create a generic type instance from a generic definition:

// Get the generic definition of List<T>
var listDefinition = TypeFactory.GetNamedType(typeof(List<>));

// Create List<string>
var stringType = TypeFactory.GetType( SpecialType.String );
var listOfString = listDefinition.MakeGenericInstance( [stringType] );

You can also use the following, more compact, syntax:

var listOfString = TypeFactory.GetNamedType( typeof(List<>) ).MakeGenericInstance( [typeof(string)] );

Tuple types

It is often convenient to use tuples when an aspect needs to pack all method arguments into a single object. They are an efficient alternative to object[].

Tuple types in Metalama are represented by ITupleType, which exposes the tuple elements under the TupleElements property. Tuple elements have a type and a name.

In C#, tuple types are syntactic sugar over the System.ValueType type. In Metalama, the System.ValueType is represented by the INamedType interface from which ITupleType is derived.

Creating tuple types

Use CreateTupleType to create a tuple type.

The following code snippet creates the tuple type (decimal Quantity, string ProductCode):

// Create a tuple type from individual types
var tupleType = TypeFactory.CreateTupleType( (typeof(decimal), "Quantity"), (typeof(string), "ProductCode" ) );

Creating and accessing tuple instances

Use CreateCreateInstanceExpression to create a tuple instantiation expression.

For instance, in a template, you can use the following code:

var tupleType = TypeFactory.CreateTupleType( (typeof(decimal), "Quantity"), (typeof(string), "ProductCode" ) );
var tupleInstance = tupleType.CreateCreateInstanceExpression(42, "HAT").Value;

This will generate the following code:

var tupleInstance = (Quantity: 42, ProductCode: "HAT");

Tuple elements are represented as fields in the tuple type. Use the following syntax to access their value:

// Get the first element of a tuple
var firstElement = tupleType.TupleElements[0].WithObject( tupleInstance ).Value;

Degenerate cases

Metalama handles special cases gracefully:

  • Zero-element tuples: Generates ValueTuple.Create().
  • One-element tuples: Generates ValueTuple<T>.
  • Two or more elements: Generates the native tuple syntax (T1 Value1, T2 Value2, ...).
// Zero arguments
var emptyTupleType = TypeFactory.CreateTupleType();
var emptyTuple = emptyTupleType.CreateCreateInstanceExpression().Value
// Result: `var emptyTuple = ValueTuple.Create();`

// One argument
var singleTupleType = TypeFactory.CreateTupleType( typeof(int) );
var singleTuple = singleTupleType.CreateCreateInstanceExpression( 42 ).Value;
// Result: `var singleTuple = ValueTuple.Create( 42 );`

// Multiple arguments
var normalTupleType = TypeFactory.CreateTupleType( (intType, "Quantity"), (stringType, "ProductCode") );
var normalTuple = normalTuple.CreateCreateInstanceExpression( 42, "HAT" );
// Result: `var normalTuple = ( Quantity: 42, ProductCode: "HAT" );`

Example: packing and unpacking arguments into a tuple

The following aspect demonstrates how you can pack all method arguments into a tuple so that they can be passed as a single object to an interceptor. The tuple is then unpacked into an argument list on the other side of the interceptor.

This example is quite convoluted because of the need to implement a basic interception pattern. You can skip it on first reading if you're just here to learn about the type system and don't want to dive into more complex aspects for now.

Despite the complexity due to the interception scenario, the aspect demonstrates the simplicity of working with tuples. The aspect code doesn't need to bother about the number of parameters. All details are taken care of by ITupleType.

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Code.Invokers;
4using Metalama.Framework.Code.SyntaxBuilders;
5using Metalama.Framework.Eligibility;
6using System.Linq;
7
8namespace Doc.TupleInterceptor;
9
10public class Intercept : MethodAspect
11{
12    public override void BuildEligibility( IEligibilityBuilder<IMethod> builder )
13    {
14        builder.ReturnType().MustEqual( SpecialType.Void );
15        builder.MustNotHaveRefOrOutParameter();
16    }
17
18    public override void BuildAspect( IAspectBuilder<IMethod> builder )
19    {
20        base.BuildAspect( builder );
21
22        // Create a tuple type for all parameters.
23        var argsType = TypeFactory.CreateTupleType( builder.Target.Parameters );
24
25        // Create the invoke method, which will be passed to the Interceptor.
26        var invokeMethod = builder.WithDeclaringType()
27            .IntroduceMethod(
28                nameof(this.InvokeTemplate),
29                args: new { argsType, originalMethod = builder.Target },
30                buildMethod:
31                m =>
32                {
33                    m.Name = builder.Target.Name + "Impl";
34                    m.Parameters[0].Type = argsType;
35                } )
36            .Declaration;
37
38        // Override the original method so that it invokes the Interceptor.
39        builder.Override( nameof(this.OverrideTemplate), args: new { argsType, invokeMethod, T = argsType } );
40    }
41
42    // Template overriding the target method.
43    [Template]
44    private void OverrideTemplate<[CompileTime] T>( ITupleType argsType, IMethod invokeMethod )
45    {
46        // Pack all arguments into a tuple.
47        var args = argsType.CreateCreateInstanceExpression( meta.Target.Parameters );
48
49        // Call the interceptor.
50        Interceptor.Intercept<T>( ExpressionFactory.Parse( invokeMethod.Name ).Value, (T) args.Value! );
51    }
52
53    // Template for the method called by the Interceptor.
54    [Template]
55    private void InvokeTemplate( dynamic args, IMethod originalMethod, ITupleType argsType )
56    {
57        // Unpack the tuple into an argument list. 
58        var argsExpression = (IExpression) args;
59        var arguments = argsType.TupleElements.Select( e => e.WithObject( argsExpression ) );
60        
61        // Invoke the original method.
62        originalMethod.WithOptions( InvokerOptions.Base ).Invoke( arguments );
63    }
64}
Source Code
1using System;
2
3namespace Doc.TupleInterceptor;
4
5public class Invoice
6{
7    [Intercept]
8    public void AddProduct( string productCode, decimal quantity )
9    {
10        Console.WriteLine( $"Adding {quantity}x {productCode}." );
11    }





12
13    [Intercept]
14    public void Cancel()
15    {
16        Console.WriteLine( $"Cancelling the order." );
17    }















18}
19
20public static class Interceptor
21{
22    // Interceptor method expecting a single payload parameter.
23    public static void Intercept<T>( Action<T> action, in T args )
24    {
25        Console.WriteLine( "Intercepted!" );
26        action( args );
27    }
28}
Transformed Code
1using System;
2
3namespace Doc.TupleInterceptor;
4
5public class Invoice
6{
7    [Intercept]
8    public void AddProduct(string productCode, decimal quantity)
9    {
10        Interceptor.Intercept(AddProductImpl, (productCode, quantity));
11    }
12
13    private void AddProduct_Source(string productCode, decimal quantity)
14    {
15        Console.WriteLine($"Adding {quantity}x {productCode}.");
16    }
17
18    [Intercept]
19    public void Cancel()
20    {
21        Interceptor.Intercept(CancelImpl, ValueTuple.Create());
22    }
23
24    private void Cancel_Source()
25    {
26        Console.WriteLine($"Cancelling the order.");
27    }
28
29    private void AddProductImpl((string productCode, decimal quantity) args)
30    {
31        AddProduct_Source(args.productCode, args.quantity);
32    }
33
34    private void CancelImpl(ValueTuple args)
35    {
36        Cancel_Source();
37    }
38}
39
40public static class Interceptor
41{
42    // Interceptor method expecting a single payload parameter.
43    public static void Intercept<T>(Action<T> action, in T args)
44    {
45        Console.WriteLine("Intercepted!");
46        action(args);
47    }
48}

Creating array types

Use the IType.MakeArrayType method to create an array type from an element type. You can optionally specify the rank (number of dimensions) as a parameter:

// Single-dimensional array (rank defaults to 1)
var intType = TypeFactory.GetType(typeof(int));
var intArrayType = intType.MakeArrayType(); // int[]

// Multi-dimensional array
var int2DArrayType = intType.MakeArrayType(2); // int[,]
var int3DArrayType = intType.MakeArrayType(3); // int[,,]

The resulting IArrayType provides access to the element type and array rank:

var arrayType = intType.MakeArrayType(2);
var elementType = arrayType.ElementType; // IType representing int
var rank = arrayType.Rank; // 2

Creating pointer types

Use the IType.MakePointerType method to create a pointer type from an element type. Note that pointers are only valid for unmanaged types:

var intType = TypeFactory.GetType(typeof(int));
var intPointerType = intType.MakePointerType(); // int*

// Pointer to pointer
var intPointerPointerType = intPointerType.MakePointerType(); // int**

The resulting IPointerType provides access to the pointed-to type:

var pointerType = intType.MakePointerType();
var pointedType = pointerType.PointedAtType; // IType representing int

Nullability

The Metalama type system handles nullability in a way that simplifies aspect development while ensuring compatibility with C#'s nullable reference types feature.

Default nullability behavior

Types returned by the Metalama type system API are non-nullable by default. This differs from Roslyn, which returns types with unknown/oblivious nullability by default.

For example, when you call GetType or access a type through the code model, the returned type will have IsNullable == false for reference types.

However, when you reflect a type member (for instance, the type of a field or parameter), the IsNullable property displays the actual nullability annotation from your code.

Checking nullability

Use the IsNullable property to check the nullability status of a type:

  • true - The type is nullable (e.g., string? or int?)
  • false - The type is non-nullable (e.g., string or int)
  • null - The nullability is unknown or oblivious (e.g., in legacy code without nullable annotations)
if ( parameter.Type.IsNullable == true )
{
    // Handle nullable type
}

Converting between nullable and non-nullable

Use the following methods to convert types between nullable and non-nullable forms:

Method Description
ToNullable() Makes a type nullable. For reference types, adds the ? annotation. For value types, returns Nullable<T>.
ToNonNullable() Makes a type non-nullable. For reference types, removes the ? annotation. For nullable value types, unwraps Nullable<T> to T.
var stringType = TypeFactory.GetType(SpecialType.String); // string (non-nullable)
var nullableString = stringType.ToNullable();              // string?
var backToNonNullable = nullableString.ToNonNullable();    // string

var intType = TypeFactory.GetType(SpecialType.Int32);      // int
var nullableInt = intType.ToNullable();                    // int? (Nullable<int>)
var backToInt = nullableInt.ToNonNullable();               // int

Adding the null-forgiving operator to expressions

When working with expressions in templates, use the WithNullForgivingOperator extension method to add the null-forgiving operator (!) to an expression:

// In a template
var expression = someField.Value;
var nonNullExpression = expression.WithNullForgivingOperator();
// Generates: someField!

By default, this method adds the operator only when the expression is nullable. Use force: true to always add it:

var forcedNonNull = expression.WithNullForgivingOperator(force: true);
Note

The null-forgiving operator is never added to non-nullable value types since they cannot be null by definition.