Generic Type Handling
Overview
Section titled “Overview”All Adapter methods in Functorium use the FinT<IO, T> return type. When ObservablePortGenerator generates Pipeline code, it must precisely extract the inner type T, as in FinT.lift<IO, T>(...). The problem is that T can be a simple type, but it can also be a nested generic like Dictionary<string, List<int>>. TypeExtractor safely parses even these complex types through a bracket counting algorithm.
Learning Objectives
Section titled “Learning Objectives”Core Learning Objectives
Section titled “Core Learning Objectives”- Extracting T from FinT<IO, T>
- How to precisely separate the second type parameter
- Parsing nested generic types
- An algorithm that tracks
<>depth with bracket counting
- An algorithm that tracks
- Using the TypeExtractor utility
- How extracted types are used in actual code generation
The Need for Generic Type Handling
Section titled “The Need for Generic Type Handling”Adapter methods use the FinT<IO, T> return type. The inner type T must be extracted during Pipeline code generation.
// Original methodpublic virtual FinT<IO, User> GetUserAsync(int id) => ...;
// Generated Pipeline codepublic override FinT<IO, User> GetUserAsync(int id) => FinT.lift<IO, User>( // <- T = User extraction needed from activityContext in IO.lift(() => CreateActivity(...)) // ... );TypeExtractor Implementation
Section titled “TypeExtractor Implementation”Full Code
Section titled “Full Code”namespace Functorium.SourceGenerators.Generators.ObservablePortGenerator;
/// <summary>/// Utility class for extracting inner types from generic types/// </summary>internal static class TypeExtractor{ /// <summary> /// Extracts the second type parameter B from a FinT<A, B> form. /// B can be a generic type (e.g., List<T>), so nested <> handling is supported. /// </summary> public static string ExtractSecondTypeParameter(string returnType) { if (string.IsNullOrEmpty(returnType)) { return returnType; }
int finTStart = returnType.IndexOf("FinT<", StringComparison.Ordinal); if (finTStart == -1) { return returnType; }
// Start after FinT< int start = finTStart + 5;
// Find comma to skip first type parameter (A) int? commaIndex = FindFirstTypeParameterSeparator(returnType, start);
if (!commaIndex.HasValue) { return returnType; }
// Start after comma (skip whitespace) start = SkipWhitespace(returnType, commaIndex.Value + 1);
// Find end of second type parameter (B) int? end = FindTypeParameterEnd(returnType, start);
if (!end.HasValue) { return returnType; }
return returnType.Substring(start, end.Value - start).Trim(); }
// ... (helper methods)}Parsing Algorithm
Section titled “Parsing Algorithm”Bracket Counting
Section titled “Bracket Counting”Bracket counting is needed to correctly parse nested generics.
Input: FinT<IO, Dictionary<string, List<int>>> ^ ^ ^ ^ ^ ^ ^^^^^^^ | | | | | | | | | | | | +-----+--- Count: 3->2->1 | | | | +------------- Count: 2 | | | +-------------------- Count: 1 (comma ignored) | | +----------------------------- Count: 1 (split here!) | +-------------------------------- Count: 1 +------------------------------------ Count: 0->1
Result: Dictionary<string, List<int>>Helper Method - Finding Comma
Section titled “Helper Method - Finding Comma”/// <summary>/// Finds the position of the comma separating the first and second type parameters./// Ignores commas inside nested generic types./// </summary>private static int? FindFirstTypeParameterSeparator(string text, int startIndex){ int bracketCount = 1; // Start at 1 because of < in FinT<
for (int i = startIndex; i < text.Length; i++) { char c = text[i];
if (c == '<') { bracketCount++; } else if (c == '>') { bracketCount--;
if (bracketCount == 0) { // Reached end of FinT but no comma found return null; } } else if (c == ',' && bracketCount == 1) { // Found comma at the first level return i; } }
return null;}Helper Method - Finding End Position
Section titled “Helper Method - Finding End Position”/// <summary>/// Finds the end position of a type parameter./// </summary>private static int? FindTypeParameterEnd(string text, int startIndex){ int bracketCount = 1; // Start at 1 because of parent FinT<
for (int i = startIndex; i < text.Length; i++) { char c = text[i];
if (c == '<') { bracketCount++; } else if (c == '>') { bracketCount--;
if (bracketCount == 0) { return i; } } }
return null;}Supported Type Patterns
Section titled “Supported Type Patterns”1. Simple Types
Section titled “1. Simple Types”// Input"FinT<IO, string>""FinT<IO, int>""FinT<IO, bool>"
// Output"string""int""bool"2. Generic Collections
Section titled “2. Generic Collections”// Input"FinT<IO, List<int>>""FinT<IO, Dictionary<string, int>>"
// Output"List<int>""Dictionary<string, int>"3. Nested Generics
Section titled “3. Nested Generics”// Input"FinT<IO, Dictionary<string, List<int>>>""FinT<IO, Result<Data<User<string>>>>"
// Output"Dictionary<string, List<int>>""Result<Data<User<string>>>"4. Fully Qualified Names
Section titled “4. Fully Qualified Names”// Input (actual use in source generator)"global::LanguageExt.FinT<global::LanguageExt.IO, global::System.Collections.Generic.List<DataResult>>"
// Output"global::System.Collections.Generic.List<DataResult>"5. Array Types
Section titled “5. Array Types”// Input"FinT<IO, string[]>""FinT<IO, int[]>"
// Output"string[]""int[]"6. Nullable Types
Section titled “6. Nullable Types”// Input"FinT<IO, int?>""FinT<IO, string?>"
// Output"int?""string?"7. Tuple Types
Section titled “7. Tuple Types”// Input"FinT<IO, (string Name, int Age)>""FinT<IO, ((int A, int B), string C)>""FinT<IO, (List<int> Numbers, string Name)>"
// Output"(string Name, int Age)""((int A, int B), string C)""(List<int> Numbers, string Name)"Usage in Code Generation
Section titled “Usage in Code Generation”During Method Generation
Section titled “During Method Generation”private static void AppendMethodOverride( StringBuilder sb, IMethodSymbol method, string className, int methodIndex){ // Extract inner type from return type string returnType = method.ReturnType.ToDisplayString( SymbolDisplayFormats.GlobalQualifiedFormat);
string innerType = TypeExtractor.ExtractSecondTypeParameter(returnType);
// Used as T in FinT.lift<IO, T> sb.Append($" global::LanguageExt.FinT.lift<global::LanguageExt.IO, {innerType}>("); // ...}Generated Code Example
Section titled “Generated Code Example”// Original: FinT<IO, List<User>> GetUsers()// Extracted type: List<User>
public override FinT<IO, List<User>> GetUsers() => FinT.lift<IO, List<User>>( // <- Extracted type used from activityContext in IO.lift(() => CreateActivity("GetUsers")) from _ in IO.lift(() => StartActivity(activityContext)) from result in FinTToIO(base.GetUsers()) from __ in IO.lift(() => { // For collections, add Count field activityContext?.SetTag("result.Count", result?.Count ?? 0); activityContext?.Dispose(); return Unit.Default; }) select result );Edge Case Handling
Section titled “Edge Case Handling”When FinT Is Not Present
Section titled “When FinT Is Not Present”// Input"string"
// TypeExtractor behaviorif (finTStart == -1) // FinT< not found{ return returnType; // Return original as-is}
// Output"string"Empty String
Section titled “Empty String”// Input""null
// TypeExtractor behaviorif (string.IsNullOrEmpty(returnType)){ return returnType;}
// Output""nullTest Scenarios
Section titled “Test Scenarios”Simple Type Test
Section titled “Simple Type Test”[Fact]public Task Should_Extract_SimpleType(){ string input = """ [GenerateObservablePort] public class DataRepository : IObservablePort { public virtual FinT<IO, int> GetNumber() => FinT<IO, int>.Succ(42); public virtual FinT<IO, string> GetText() => FinT<IO, string>.Succ("hello"); public virtual FinT<IO, bool> GetFlag() => FinT<IO, bool>.Succ(true); } """;
string? actual = _sut.Generate(input);
// Verify FinT.lift<IO, int>, FinT.lift<IO, string>, FinT.lift<IO, bool> return Verify(actual);}Collection Type Test
Section titled “Collection Type Test”[Fact]public Task Should_Extract_CollectionType(){ string input = """ public class User { public int Id { get; set; } }
[GenerateObservablePort] public class UserRepository : IObservablePort { public virtual FinT<IO, List<User>> GetUsers() => FinT<IO, List<User>>.Succ(new List<User>()); public virtual FinT<IO, string[]> GetNames() => FinT<IO, string[]>.Succ(Array.Empty<string>()); } """;
string? actual = _sut.Generate(input);
// Verify List<User>, string[] extraction return Verify(actual);}Complex Generic Test
Section titled “Complex Generic Test”[Fact]public Task Should_Extract_ComplexGenericType(){ string input = """ [GenerateObservablePort] public class DataRepository : IObservablePort { public virtual FinT<IO, Dictionary<string, List<int>>> GetComplexData() => FinT<IO, Dictionary<string, List<int>>>.Succ( new Dictionary<string, List<int>>()); } """;
string? actual = _sut.Generate(input);
// Verify Dictionary<string, List<int>> extraction return Verify(actual);}Tuple Type Test
Section titled “Tuple Type Test”[Fact]public Task Should_Extract_TupleType(){ string input = """ [GenerateObservablePort] public class UserRepository : IObservablePort { public virtual FinT<IO, (int Id, string Name)> GetUserInfo() => FinT<IO, (int Id, string Name)>.Succ((1, "Test")); } """;
string? actual = _sut.Generate(input);
// Verify (int Id, string Name) extraction return Verify(actual);}Summary at a Glance
Section titled “Summary at a Glance”The core of TypeExtractor is the bracket counting algorithm. It increments the count when encountering < and decrements when encountering >, separating the first and second type parameters at the comma where the count is 1. This approach works accurately regardless of nesting depth.
| Pattern | Input Example | Output |
|---|---|---|
| Simple type | FinT<IO, int> | int |
| Collection | FinT<IO, List<T>> | List<T> |
| Nested generic | FinT<IO, Dict<K, List<V>>> | Dict<K, List<V>> |
| Array | FinT<IO, T[]> | T[] |
| Tuple | FinT<IO, (A, B)> | (A, B) |
| Nullable | FinT<IO, T?> | T? |
Q1: Are there cases where the bracket counting algorithm fails?
Section titled “Q1: Are there cases where the bracket counting algorithm fails?”A: For return types that are not in FinT<IO, T> form (e.g., plain string), the FinT< pattern is not found, and the original string is returned as-is. This is intentional behavior, and since all methods processed by ObservablePortGenerator are in FinT<IO, T> form, no issues actually occur in practice.
Q2: Does type extraction work correctly with Fully Qualified Names (including global:: prefix)?
Section titled “Q2: Does type extraction work correctly with Fully Qualified Names (including global:: prefix)?”A: Yes. TypeExtractor finds the position of the FinT< pattern within the string using IndexOf, then performs bracket counting from that point onward. Even with long Fully Qualified Names like global::LanguageExt.FinT<global::LanguageExt.IO, global::System.Collections.Generic.List<DataResult>>, the second type parameter is extracted accurately.
Q3: Can TypeExtractor be replaced with IMethodSymbol.ReturnType’s TypeArguments?
Section titled “Q3: Can TypeExtractor be replaced with IMethodSymbol.ReturnType’s TypeArguments?”A: Roslyn’s INamedTypeSymbol.TypeArguments allows direct access to type parameters at the symbol level. However, since ObservablePortGenerator generates code based on strings already converted by SymbolDisplayFormat, the string parsing approach maintains consistency with the rest of the pipeline.
Now that type extraction is possible, we will learn how to determine whether an extracted type is a collection and automatically generate Count/Length tags.