You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Advanced Type System

Deep dive into generics, variance, interfaces, and type-level programming

Advanced Type System in C#

Master C#'s advanced type system with free flashcards and spaced repetition practice. This lesson covers generics with constraints, covariance and contravariance, nullable reference types, pattern matching, and advanced type featuresβ€”essential concepts for building robust, type-safe applications in modern C#.

Welcome to Advanced Type System πŸ’»

C# offers one of the most sophisticated type systems in modern programming languages. Beyond basic classes and interfaces, C# provides powerful features like generic constraints, variance annotations, nullable reference types, and advanced pattern matching that enable you to write more expressive, safer, and maintainable code.

In this lesson, you'll explore how to leverage these advanced type system features to catch bugs at compile time, create flexible reusable components, and express complex domain logic with clarity and precision.

Core Concepts

1. Generic Constraints πŸ”

Generics allow you to write type-safe code that works with multiple types. Generic constraints let you specify requirements that type parameters must satisfy, enabling you to call specific methods or ensure certain capabilities.

ConstraintSyntaxRequirement
Class constraintwhere T : ClassNameT must be or derive from ClassName
Interface constraintwhere T : IInterfaceT must implement IInterface
Struct constraintwhere T : structT must be a value type
Class constraintwhere T : classT must be a reference type
New constraintwhere T : new()T must have a parameterless constructor
Unmanaged constraintwhere T : unmanagedT must be an unmanaged type
Notnull constraintwhere T : notnullT must be a non-nullable type

πŸ’‘ Tip: You can combine multiple constraints using commas: where T : class, IComparable<T>, new()

Why use constraints? They unlock methods and properties on your generic type parameters. Without constraints, you can only use methods available on object.

2. Covariance and Contravariance πŸ”„

Variance describes how subtyping relationships between types relate to subtyping relationships of more complex types constructed from them.

  • Covariance (out keyword): Allows you to use a more derived type than originally specified. Used for return types.
  • Contravariance (in keyword): Allows you to use a less derived type than originally specified. Used for parameter types.
  • Invariance: No variance annotation - type must match exactly.
VARIANCE VISUALIZATION

Covariance (out):
  IEnumerable animals = IEnumerable
  └─ Can return MORE SPECIFIC type β”€β”˜
     (Dog is more specific than Animal)

Contravariance (in):
  Action dogAction = Action
  └─ Can accept LESS SPECIFIC type β”€β”˜
     (Animal is less specific than Dog)

Invariance (no annotation):
  List β‰  List
  └─ MUST BE EXACT MATCH β”€β”˜

🧠 Memory Device:

  • Covariant = Comes Out (return values)
  • Contravariant = goes In (parameters)

Common covariant interfaces:

  • IEnumerable<out T>
  • IEnumerator<out T>
  • Func<out TResult>

Common contravariant interfaces:

  • Action<in T>
  • IComparer<in T>
  • Func<in T, out TResult> (contravariant in T, covariant in TResult)

3. Nullable Reference Types πŸ›‘οΈ

Introduced in C# 8.0, nullable reference types help prevent null reference exceptions by distinguishing between nullable and non-nullable reference types at compile time.

DeclarationMeaningExample
stringNon-nullable referenceCannot be null (compiler warning if null)
string?Nullable referenceCan explicitly be null
string!Null-forgiving operatorSuppress null warning

Enabling nullable reference types:

// In .csproj file
<Nullable>enable</Nullable>

// Or in code file
#nullable enable

πŸ’‘ Tip: The null-forgiving operator ! tells the compiler "I know this looks null but trust me, it isn't." Use sparingly!

Null-handling operators:

  • ?. (null-conditional): obj?.Method() returns null if obj is null
  • ?? (null-coalescing): value ?? defaultValue returns defaultValue if value is null
  • ??= (null-coalescing assignment): value ??= defaultValue assigns if value is null

4. Pattern Matching 🎯

C# pattern matching allows you to test whether a value has a certain "shape" and extract information from it when it does.

Pattern types:

PatternSyntaxUse Case
Type patternobj is int iTest type and capture value
Constant patternx is nullTest against constant value
Var patternobj is var vAlways matches, captures value
Property patternobj is { Prop: value }Test property values
Positional patternpoint is (0, 0)Deconstruct and test
Relational patternx is > 0 and < 100Compare with operators
Logical patternx is not nullCombine with and/or/not

Switch expressions (C# 8.0+) provide a concise way to use pattern matching:

var result = value switch
{
    0 => "zero",
    > 0 => "positive",
    < 0 => "negative"
};

5. Records and Init-Only Properties πŸ“

Records (C# 9.0+) are reference types designed for immutable data with value-based equality.

Key features:

  • Value-based equality (compares values, not references)
  • Built-in ToString() override
  • Non-destructive mutation with with expressions
  • Positional syntax for concise declarations

Init-only properties can only be set during object initialization:

public class Person
{
    public string Name { get; init; }
    public int Age { get; init; }
}

var person = new Person { Name = "Alice", Age = 30 };
// person.Name = "Bob"; // Error: init-only property

6. Type Aliases and Using Directives 🏷️

C# allows creating type aliases for better readability:

// Global using alias (C# 10+)
global using UserId = System.Guid;

// File-scoped using alias
using Point = (double X, double Y);

⚠️ Common Mistake: Type aliases are compile-time only. At runtime, UserId and Guid are the same type.

Detailed Examples

Example 1: Generic Repository with Constraints πŸ’Ύ

public interface IEntity
{
    int Id { get; set; }
}

public class Repository<T> where T : class, IEntity, new()
{
    private readonly List<T> _items = new();

    public void Add(T item)
    {
        // Can access Id because of IEntity constraint
        if (_items.Any(x => x.Id == item.Id))
        {
            throw new InvalidOperationException("Duplicate ID");
        }
        _items.Add(item);
    }

    public T CreateNew()
    {
        // Can create instance because of new() constraint
        return new T();
    }

    public T? GetById(int id)
    {
        // Returns nullable T due to nullable reference types
        return _items.FirstOrDefault(x => x.Id == id);
    }
}

public class Product : IEntity
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

// Usage
var repo = new Repository<Product>();
var newProduct = repo.CreateNew();
newProduct.Id = 1;
newProduct.Name = "Laptop";
newProduct.Price = 999.99m;
repo.Add(newProduct);

Why this works:

  • where T : class ensures T is a reference type
  • where T : IEntity allows accessing the Id property
  • where T : new() enables creating instances with new T()
  • Multiple constraints combine to create a powerful, type-safe repository

Example 2: Covariance and Contravariance in Action πŸ”„

// Animal hierarchy
public class Animal
{
    public string Name { get; set; } = string.Empty;
    public virtual void MakeSound() => Console.WriteLine("Some sound");
}

public class Dog : Animal
{
    public override void MakeSound() => Console.WriteLine("Woof!");
}

public class Cat : Animal
{
    public override void MakeSound() => Console.WriteLine("Meow!");
}

// Covariance example (out)
public interface IAnimalFactory<out T> where T : Animal
{
    T CreateAnimal();
}

public class DogFactory : IAnimalFactory<Dog>
{
    public Dog CreateAnimal() => new Dog { Name = "Buddy" };
}

// Contravariance example (in)
public interface IAnimalProcessor<in T> where T : Animal
{
    void Process(T animal);
}

public class AnimalProcessor : IAnimalProcessor<Animal>
{
    public void Process(Animal animal)
    {
        Console.WriteLine($"Processing {animal.Name}");
        animal.MakeSound();
    }
}

// Usage demonstrating variance
public class VarianceDemo
{
    public void DemonstrateCovariance()
    {
        // Covariance: IAnimalFactory<Dog> can be assigned to IAnimalFactory<Animal>
        IAnimalFactory<Dog> dogFactory = new DogFactory();
        IAnimalFactory<Animal> animalFactory = dogFactory; // Valid due to 'out'
        
        Animal animal = animalFactory.CreateAnimal();
        animal.MakeSound(); // Outputs: Woof!
    }

    public void DemonstrateContravariance()
    {
        // Contravariance: IAnimalProcessor<Animal> can be assigned to IAnimalProcessor<Dog>
        IAnimalProcessor<Animal> animalProcessor = new AnimalProcessor();
        IAnimalProcessor<Dog> dogProcessor = animalProcessor; // Valid due to 'in'
        
        Dog dog = new Dog { Name = "Max" };
        dogProcessor.Process(dog); // Outputs: Processing Max, Woof!
    }
}

Key insights:

  • out T (covariance) works because the interface only returns T, never accepts it as input
  • in T (contravariance) works because the interface only accepts T as input, never returns it
  • This enables more flexible API design while maintaining type safety

Example 3: Advanced Pattern Matching 🎯

public record Point(double X, double Y);

public record Shape
{
    public record Circle(Point Center, double Radius) : Shape;
    public record Rectangle(Point TopLeft, double Width, double Height) : Shape;
    public record Triangle(Point A, Point B, Point C) : Shape;
}

public class ShapeAnalyzer
{
    public string AnalyzeShape(Shape shape) => shape switch
    {
        // Property pattern with nested patterns
        Shape.Circle { Radius: > 10 } => "Large circle",
        Shape.Circle { Radius: <= 10 and > 0 } c => $"Small circle at {c.Center}",
        
        // Positional pattern with deconstruction
        Shape.Rectangle(_, var width, var height) when width == height 
            => "Square",
        Shape.Rectangle(_, var width, var height) 
            => $"Rectangle {width}x{height}",
        
        // Multiple property checks
        Shape.Triangle { A: (0, 0), B: var b, C: var c } 
            => $"Triangle from origin to {b} and {c}",
        
        // Catch-all
        _ => "Unknown shape"
    };

    public double CalculateArea(Shape shape) => shape switch
    {
        Shape.Circle(_, var r) => Math.PI * r * r,
        Shape.Rectangle(_, var w, var h) => w * h,
        Shape.Triangle(var a, var b, var c) => CalculateTriangleArea(a, b, c),
        _ => 0
    };

    private double CalculateTriangleArea(Point a, Point b, Point c)
    {
        // Heron's formula
        double ab = Distance(a, b);
        double bc = Distance(b, c);
        double ca = Distance(c, a);
        double s = (ab + bc + ca) / 2;
        return Math.Sqrt(s * (s - ab) * (s - bc) * (s - ca));
    }

    private double Distance(Point p1, Point p2)
    {
        return Math.Sqrt(Math.Pow(p2.X - p1.X, 2) + Math.Pow(p2.Y - p1.Y, 2));
    }
}

// Usage with nullable reference types
public class ShapeProcessor
{
    public void ProcessShape(Shape? shape)
    {
        // Null-checking pattern
        if (shape is null)
        {
            Console.WriteLine("No shape provided");
            return;
        }

        // Type pattern with capture
        if (shape is Shape.Circle circle)
        {
            Console.WriteLine($"Circle with radius {circle.Radius}");
        }

        // Not pattern (C# 9.0+)
        if (shape is not Shape.Triangle)
        {
            Console.WriteLine("Not a triangle");
        }
    }
}

Pattern matching benefits:

  • Concise and readable code
  • Compile-time exhaustiveness checking with switch expressions
  • Natural expression of business logic
  • Safe deconstruction and property access

Example 4: Combining Records, Init Properties, and With Expressions πŸ“

// Record with init-only properties
public record Person
{
    public string FirstName { get; init; } = string.Empty;
    public string LastName { get; init; } = string.Empty;
    public DateOnly BirthDate { get; init; }
    public Address? Address { get; init; }
    
    // Computed property
    public string FullName => $"{FirstName} {LastName}";
    
    // Custom validation
    public int Age
    {
        get
        {
            var today = DateOnly.FromDateTime(DateTime.Today);
            var age = today.Year - BirthDate.Year;
            if (BirthDate > today.AddYears(-age)) age--;
            return age;
        }
    }
}

public record Address
{
    public string Street { get; init; } = string.Empty;
    public string City { get; init; } = string.Empty;
    public string PostalCode { get; init; } = string.Empty;
    public string Country { get; init; } = string.Empty;
}

public class PersonService
{
    public Person CreatePerson(string firstName, string lastName, DateOnly birthDate)
    {
        return new Person
        {
            FirstName = firstName,
            LastName = lastName,
            BirthDate = birthDate
        };
    }

    public Person UpdateAddress(Person person, Address newAddress)
    {
        // Non-destructive mutation with 'with' expression
        return person with { Address = newAddress };
    }

    public Person UpdateLastName(Person person, string newLastName)
    {
        // Only the specified property is changed
        return person with { LastName = newLastName };
    }

    public bool AreSamePerson(Person person1, Person person2)
    {
        // Value-based equality (compares all properties)
        return person1 == person2;
    }
}

// Usage
var person = new Person
{
    FirstName = "Alice",
    LastName = "Smith",
    BirthDate = new DateOnly(1990, 5, 15)
};

var address = new Address
{
    Street = "123 Main St",
    City = "Springfield",
    PostalCode = "12345",
    Country = "USA"
};

var service = new PersonService();
var personWithAddress = service.UpdateAddress(person, address);

// person and personWithAddress are different instances
// but share all properties except Address
Console.WriteLine(person.FullName); // Alice Smith
Console.WriteLine(personWithAddress.Address?.City); // Springfield

// Value equality
var person2 = new Person
{
    FirstName = "Alice",
    LastName = "Smith",
    BirthDate = new DateOnly(1990, 5, 15)
};

Console.WriteLine(person == person2); // True (same values)
Console.WriteLine(ReferenceEquals(person, person2)); // False (different instances)

Why records are powerful:

  • Immutability by default prevents accidental state changes
  • Value-based equality matches domain modeling needs
  • with expressions enable clean updates without verbose constructors
  • Reduced boilerplate compared to traditional classes

Common Mistakes ⚠️

1. Misunderstanding Variance

❌ Wrong:

// This doesn't compile - List<T> is invariant
List<Animal> animals = new List<Dog>(); // Error!

βœ… Right:

// Use covariant IEnumerable<out T> instead
IEnumerable<Animal> animals = new List<Dog>(); // OK!

Why: List<T> allows both reading and writing, so it can't be variant. Use read-only interfaces like IEnumerable<T> for covariance.

2. Forgetting to Enable Nullable Reference Types

❌ Wrong:

// Without #nullable enable, this compiles but can throw NullReferenceException
public string GetName(Person person)
{
    return person.Name.ToUpper(); // Potential null reference
}

βœ… Right:

#nullable enable
public string GetName(Person? person)
{
    if (person?.Name is null)
    {
        return "Unknown";
    }
    return person.Name.ToUpper();
}

3. Overusing the Null-Forgiving Operator

❌ Wrong:

// Suppressing warnings without verifying
public void ProcessData(string? input)
{
    var data = input!.Trim(); // Could still be null at runtime!
}

βœ… Right:

public void ProcessData(string? input)
{
    if (input is null)
    {
        throw new ArgumentNullException(nameof(input));
    }
    var data = input.Trim(); // Safe - null checked
}

4. Constraining Generic Types Too Much

❌ Wrong:

// Overly restrictive - limits reusability
public class Cache<T> where T : class, ISerializable, IDisposable, new()
{
    // Too many constraints limit what types can be used
}

βœ… Right:

// Only constrain what's necessary
public class Cache<T> where T : class
{
    // More flexible, can work with more types
}

πŸ’‘ Tip: Start with minimal constraints and add only what you need.

5. Modifying Records Incorrectly

❌ Wrong:

public record Product
{
    public string Name { get; set; } // Mutable - defeats record purpose!
    public decimal Price { get; set; }
}

βœ… Right:

public record Product
{
    public string Name { get; init; } // Immutable with init
    public decimal Price { get; init; }
}

// Update using 'with' expression
var product = new Product { Name = "Widget", Price = 9.99m };
var updatedProduct = product with { Price = 12.99m };

6. Ignoring Pattern Matching Order

❌ Wrong:

// Unreachable pattern - catch-all comes first
var result = value switch
{
    _ => "default", // This catches everything!
    > 0 => "positive", // Never reached
    < 0 => "negative" // Never reached
};

βœ… Right:

// Most specific patterns first, catch-all last
var result = value switch
{
    > 0 => "positive",
    < 0 => "negative",
    _ => "zero"
};

Key Takeaways 🎯

πŸ“‹ Quick Reference Card

Generic ConstraintsRestrict type parameters: where T : class, IInterface, new()
Covariance (out)Return more derived types: IEnumerable<out T>
Contravariance (in)Accept less derived types: Action<in T>
Nullable Referencesstring? = nullable, string = non-nullable
Null Operators?. conditional, ?? coalescing, ??= assignment
Pattern MatchingTest and extract: obj is Type t, switch expressions
RecordsImmutable data: record Person(string Name)
Init PropertiesSet during initialization: { get; init; }
With ExpressionsNon-destructive update: person with { Age = 30 }

Remember:

  1. πŸ” Use constraints to unlock capabilities on generic types
  2. πŸ”„ Understand variance to create flexible APIs (out for covariance, in for contravariance)
  3. πŸ›‘οΈ Enable nullable reference types to catch null errors at compile time
  4. 🎯 Leverage pattern matching for expressive, safe code
  5. πŸ“ Choose records for immutable data with value semantics
  6. ⚠️ Apply the principle of least constraint - only restrict what's necessary

Further Study πŸ“š

  1. Microsoft Docs - Generics: https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/generics
  2. Microsoft Docs - Covariance and Contravariance: https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/
  3. Microsoft Docs - Nullable Reference Types: https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references

πŸŽ“ Next Steps: Practice implementing generic collections with constraints, experiment with variance in your own interfaces, and enable nullable reference types in a real project to see how they improve code safety.


🧠 Did you know? C#'s type system is gradually typed - it supports both static typing (checked at compile time) and dynamic typing (checked at runtime with the dynamic keyword). This flexibility makes C# suitable for both high-performance applications and scenarios requiring runtime type flexibility!