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.
| Constraint | Syntax | Requirement |
|---|---|---|
| Class constraint | where T : ClassName | T must be or derive from ClassName |
| Interface constraint | where T : IInterface | T must implement IInterface |
| Struct constraint | where T : struct | T must be a value type |
| Class constraint | where T : class | T must be a reference type |
| New constraint | where T : new() | T must have a parameterless constructor |
| Unmanaged constraint | where T : unmanaged | T must be an unmanaged type |
| Notnull constraint | where T : notnull | T 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 (
outkeyword): Allows you to use a more derived type than originally specified. Used for return types. - Contravariance (
inkeyword): 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): IEnumerableanimals = 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.
| Declaration | Meaning | Example |
|---|---|---|
string | Non-nullable reference | Cannot be null (compiler warning if null) |
string? | Nullable reference | Can explicitly be null |
string! | Null-forgiving operator | Suppress 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 ?? defaultValuereturns defaultValue if value is null??=(null-coalescing assignment):value ??= defaultValueassigns 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:
| Pattern | Syntax | Use Case |
|---|---|---|
| Type pattern | obj is int i | Test type and capture value |
| Constant pattern | x is null | Test against constant value |
| Var pattern | obj is var v | Always matches, captures value |
| Property pattern | obj is { Prop: value } | Test property values |
| Positional pattern | point is (0, 0) | Deconstruct and test |
| Relational pattern | x is > 0 and < 100 | Compare with operators |
| Logical pattern | x is not null | Combine 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
withexpressions - 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 : classensures T is a reference typewhere T : IEntityallows accessing theIdpropertywhere T : new()enables creating instances withnew 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 inputin 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
withexpressions 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 Constraints | Restrict 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 References | string? = nullable, string = non-nullable |
| Null Operators | ?. conditional, ?? coalescing, ??= assignment |
| Pattern Matching | Test and extract: obj is Type t, switch expressions |
| Records | Immutable data: record Person(string Name) |
| Init Properties | Set during initialization: { get; init; } |
| With Expressions | Non-destructive update: person with { Age = 30 } |
Remember:
- π Use constraints to unlock capabilities on generic types
- π Understand variance to create flexible APIs (out for covariance, in for contravariance)
- π‘οΈ Enable nullable reference types to catch null errors at compile time
- π― Leverage pattern matching for expressive, safe code
- π Choose records for immutable data with value semantics
- β οΈ Apply the principle of least constraint - only restrict what's necessary
Further Study π
- Microsoft Docs - Generics: https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/generics
- Microsoft Docs - Covariance and Contravariance: https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/
- 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!