Skip to content

TypeFormatter: Type Name Formatting Utility

Verified by tests

TypeFormatterTests, TypeNameFormatterTests — library CI run #31657041675 (2026-08-13)

TypeFormatter is a static utility class that formats .NET Type objects into string representations according to TypeQualifications flags. It handles namespace, assembly, version, culture, and public key token formatting with culture-invariant output.

Overview

TypeFormatter provides: - ✅ Culture-invariant type name formatting - ✅ Respects all TypeQualifications flags - ✅ Handles null types safely - ✅ Fully AOT-compatible (no reflection beyond Type.GetName) - ✅ Used by source generators and message association APIs

Quick Start

Basic Formatting

Basic Formatting

using Whizbang.Core;

var type = typeof(ECommerce.Contracts.Events.ProductCreatedEvent);

// Format with preset
var simple = TypeFormatter.FormatType(type, TypeQualifications.Simple);
Console.WriteLine(simple);
// Output: "ProductCreatedEvent"

var fullyQualified = TypeFormatter.FormatType(type, TypeQualifications.FullyQualified);
Console.WriteLine(fullyQualified);
// Output: "ECommerce.Contracts.Events.ProductCreatedEvent, ECommerce.Contracts"

// Format with custom flags
var custom = TypeFormatter.FormatType(
    type,
    TypeQualifications.Namespace | TypeQualifications.TypeName
);
Console.WriteLine(custom);
// Output: "ECommerce.Contracts.Events.ProductCreatedEvent"

Formatting with Version Information

Formatting with Version Information

// Full assembly qualification with version
var withVersion = TypeFormatter.FormatType(
    type,
    TypeQualifications.FullyQualifiedWithVersion
);
Console.WriteLine(withVersion);
// Output: "ECommerce.Contracts.Events.ProductCreatedEvent, ECommerce.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"

// Without version
var withoutVersion = TypeFormatter.FormatType(
    type,
    TypeQualifications.FullyQualified
);
Console.WriteLine(withoutVersion);
// Output: "ECommerce.Contracts.Events.ProductCreatedEvent, ECommerce.Contracts"

Formatting Rules

Component Order

TypeFormatter outputs components in this order: 1. GlobalPrefix (global::) - if flag is set 2. Namespace (MyApp.Events) - if flag is set 3. Dot separator (.) - if both Namespace and TypeName are set 4. TypeName (ProductCreatedEvent) - if flag is set 5. Comma separator (,) - before Assembly, if anything precedes it (omitted when Assembly is the only component) 6. Assembly (MyApp) - if flag is set 7. Version (, Version=1.0.0.0) - if flag is set 8. Culture (, Culture=neutral) - if flag is set 9. PublicKeyToken (, PublicKeyToken=null) - if flag is set

Flag Combinations

Flag Combinations

var type = typeof(OrderCreatedEvent);

// TypeName only
var name = TypeFormatter.FormatType(type, TypeQualifications.TypeName);
// Result: "OrderCreatedEvent"

// Namespace + TypeName
var ns = TypeFormatter.FormatType(
    type,
    TypeQualifications.Namespace | TypeQualifications.TypeName
);
// Result: "MyApp.Events.OrderCreatedEvent"

// GlobalPrefix + TypeName
var global = TypeFormatter.FormatType(
    type,
    TypeQualifications.GlobalPrefix | TypeQualifications.TypeName
);
// Result: "global::OrderCreatedEvent"

// GlobalPrefix + Namespace + TypeName
var globalFull = TypeFormatter.FormatType(
    type,
    TypeQualifications.GlobalPrefix | TypeQualifications.Namespace | TypeQualifications.TypeName
);
// Result: "global::MyApp.Events.OrderCreatedEvent"

// Assembly without TypeName (edge case)
var assemblyOnly = TypeFormatter.FormatType(type, TypeQualifications.Assembly);
// Result: "MyApp"

// None flag
var empty = TypeFormatter.FormatType(type, TypeQualifications.None);
// Result: ""

Culture-Invariant Formatting

TypeFormatter uses CultureInfo.InvariantCulture for all formatting to ensure consistent output across locales:

Culture-Invariant Formatting

// Version, Culture, and PublicKeyToken always use InvariantCulture
var withVersion = TypeFormatter.FormatType(
    type,
    TypeQualifications.FullyQualifiedWithVersion
);

// Formatted string interpolation uses InvariantCulture
// This ensures version numbers, hex strings, etc. are consistent
// Example: "Version=1.0.0.0" not "Version=1,0,0,0" (some locales use commas)

Common Scenarios

Scenario 1: Source Generator Output

When: Generating C# code that references types

Scenario 1: Source Generator Output

public string GenerateEventHandler(Type eventType) {
    // Use GlobalQualified to avoid namespace conflicts
    var typeName = TypeFormatter.FormatType(
        eventType,
        TypeQualifications.GlobalQualified
    );

    return $@"
public class GeneratedHandler {{
    public void Handle({typeName} evt) {{
        // Handle event
    }}
}}
";
}

// Output:
// public class GeneratedHandler {
//     public void Handle(global::ECommerce.Contracts.Events.ProductCreatedEvent evt) {
//         // Handle event
//     }
// }

Scenario 2: Logging and Diagnostics

When: Displaying type information in logs

Scenario 2: Logging and Diagnostics

public void LogTypeInfo(Type type) {
    // Simple name for user-friendly output
    var simple = TypeFormatter.FormatType(type, TypeQualifications.Simple);
    _logger.LogInformation("Processing: {TypeName}", simple);

    // Fully qualified for diagnostic details
    var full = TypeFormatter.FormatType(type, TypeQualifications.FullyQualified);
    _logger.LogDebug("Full type: {FullType}", full);

    // With version for complete diagnostics
    var withVersion = TypeFormatter.FormatType(
        type,
        TypeQualifications.FullyQualifiedWithVersion
    );
    _logger.LogTrace("Type with version: {VersionedType}", withVersion);
}

// Output:
// Information: Processing: ProductCreatedEvent
// Debug: Full type: ECommerce.Contracts.Events.ProductCreatedEvent, ECommerce.Contracts
// Trace: Type with version: ECommerce.Contracts.Events.ProductCreatedEvent, ECommerce.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null

Scenario 3: Configuration and Serialization

When: Storing type names in configuration or serializing to JSON

Scenario 3: Configuration and Serialization

public class EventConfiguration {
    // Store fully qualified name for reliable deserialization
    public string EventType { get; set; } = null!;
}

public EventConfiguration CreateConfig(Type eventType) {
    return new EventConfiguration {
        EventType = TypeFormatter.FormatType(
            eventType,
            TypeQualifications.FullyQualified
        )
    };
}

// Later: Deserialize
public Type GetEventType(EventConfiguration config) {
    // Use fully qualified name for reliable Type.GetType()
    return Type.GetType(config.EventType)
        ?? throw new InvalidOperationException($"Type not found: {config.EventType}");
}

Scenario 4: Dynamic Type Display

When: Building UI that shows type information

Scenario 4: Dynamic Type Display

public class TypeDisplayInfo {
    public string SimpleName { get; init; } = null!;
    public string FullName { get; init; } = null!;
    public string AssemblyName { get; init; } = null!;
}

public TypeDisplayInfo GetDisplayInfo(Type type) {
    return new TypeDisplayInfo {
        SimpleName = TypeFormatter.FormatType(type, TypeQualifications.Simple),
        FullName = TypeFormatter.FormatType(type, TypeQualifications.NamespaceQualified),
        AssemblyName = TypeFormatter.FormatType(type, TypeQualifications.Assembly)
    };
}

// Usage in UI:
// Simple: "ProductCreatedEvent"
// Full: "ECommerce.Contracts.Events.ProductCreatedEvent"
// Assembly: "ECommerce.Contracts"

Edge Cases and Special Handling

Empty Namespace

Empty Namespace

// Type with no namespace (global namespace)
public class GlobalType { }

var formatted = TypeFormatter.FormatType(
    typeof(GlobalType),
    TypeQualifications.NamespaceQualified
);
// Result: "GlobalType" (no leading dot)

Generic Types

Generic Types

var genericType = typeof(List<OrderCreatedEvent>);

var formatted = TypeFormatter.FormatType(
    genericType,
    TypeQualifications.FullyQualified
);
// Result: "System.Collections.Generic.List`1, System.Private.CoreLib"
// Note: TypeFormatter uses Type.Name, so generic arity appears as `1, `2, etc.
// Generic type arguments are NOT expanded.

Nested Types

Nested Types

public class OuterClass {
    public class InnerClass { }
}

var nestedType = typeof(OuterClass.InnerClass);
var formatted = TypeFormatter.FormatType(
    nestedType,
    TypeQualifications.NamespaceQualified
);
// Result: "MyApp.InnerClass"
// Note: TypeFormatter uses Type.Name, which for a nested type is just the
// inner type's name — the declaring type is NOT included. If you need the
// full '+'-separated nested chain ("MyApp.OuterClass+InnerClass"), use
// TypeNameFormatter (below), which is based on Type.FullName.

Public Key Token

Public Key Token

// Strong-named assembly
var strongType = typeof(System.String);

var withToken = TypeFormatter.FormatType(
    strongType,
    TypeQualifications.FullyQualifiedWithVersion
);
// Result: "System.String, System.Private.CoreLib, Version=<runtime version>, Culture=neutral, PublicKeyToken=7cec85d7bea7798e"

// Non-strong-named assembly
var weakType = typeof(MyApp.CustomType);

var withoutToken = TypeFormatter.FormatType(
    weakType,
    TypeQualifications.FullyQualifiedWithVersion
);
// Result: "MyApp.CustomType, MyApp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"

Performance Considerations

StringBuilder Allocation

TypeFormatter uses StringBuilder internally for efficient string building:

StringBuilder Allocation

// Efficient - single StringBuilder allocation
var formatted = TypeFormatter.FormatType(type, TypeQualifications.FullyQualified);

// Less efficient - multiple string concatenations
var manual = type.Namespace + "." + type.Name + ", " + type.Assembly.GetName().Name;

Caching Formatted Results

Since type formatting is deterministic, consider caching results:

Caching Formatted Results

public class CachedTypeFormatter {
    private readonly ConcurrentDictionary<(Type, TypeQualifications), string> _cache = new();

    public string FormatType(Type type, TypeQualifications qualification) {
        return _cache.GetOrAdd(
            (type, qualification),
            key => TypeFormatter.FormatType(key.Item1, key.Item2)
        );
    }
}

Integration with Message Associations

TypeFormatter is used extensively in message association APIs:

Integration with Message Associations

// Format event type for lookup
var eventType = typeof(ProductCreatedEvent);
var simpleType = TypeFormatter.FormatType(eventType, TypeQualifications.Simple);

// Find perspectives handling this event (simple name)
var perspectives = PerspectiveRegistrationExtensions.GetPerspectivesForEvent(
    simpleType,
    serviceName,
    MatchStrictness.SimpleName
);

// Format for storage in message associations
var storedType = TypeFormatter.FormatType(eventType, TypeQualifications.FullyQualified);

API Reference

Method Signature

Namespace: Whizbang.Core

Method Signature

public static class TypeFormatter {
    /// <summary>
    /// Formats a Type according to the specified TypeQualifications flags.
    /// Uses culture-invariant formatting for consistent output.
    /// </summary>
    /// <param name="type">The Type to format</param>
    /// <param name="qualification">Flags controlling which components to include</param>
    /// <returns>Formatted type name string</returns>
    /// <exception cref="ArgumentNullException">Thrown if type is null</exception>
    public static string FormatType(Type type, TypeQualifications qualification);

    /// <summary>
    /// Parses the assembly name out of a fully qualified type name string
    /// (e.g. "Namespace.Type, Assembly, Version=1.0.0.0, ...").
    /// With stripVersion: true returns just the assembly name; with false,
    /// returns the full assembly string including version/culture/token.
    /// Returns empty string when no assembly info is present.
    /// </summary>
    public static string ParseAssemblyName(string fullTypeName, bool stripVersion);
}

Parameters

  • type: The Type object to format (cannot be null)
  • qualification: TypeQualifications flags controlling output format

Return Value

  • Returns formatted type name as string
  • Returns empty string if qualification is TypeQualifications.None
  • Never returns null

Exceptions

  • ArgumentNullException: Thrown if type parameter is null

Best Practices

  1. Use FullyQualified for persistence - Ensures reliable deserialization with Type.GetType()
  2. Use Simple for user-facing displays - More readable in UI and logs
  3. Use GlobalQualified in generated code - Avoids namespace conflicts with global::
  4. Cache formatted results - Formatting is expensive, memoize when calling frequently
  5. Use culture-invariant output - TypeFormatter already does this, safe for serialization
  6. Avoid formatting in hot paths - Pre-format and cache if used repeatedly
  7. Consider version implications - Decide if version matching matters for your use case

Common Pitfalls

❌ Formatting Null Types

❌ Formatting Null Types

// ❌ WRONG: Null type
Type? nullType = null;
var formatted = TypeFormatter.FormatType(nullType!, TypeQualifications.Simple);
// Throws: ArgumentNullException

// ✅ CORRECT: Check for null first
if (type != null) {
    var formatted = TypeFormatter.FormatType(type, TypeQualifications.Simple);
}

❌ Assuming Default Format

❌ Assuming Default Format

// ❌ WRONG: Assuming ToString() matches formatted output
var toString = type.ToString();
var formatted = TypeFormatter.FormatType(type, TypeQualifications.FullyQualified);
// These may not match!

// ✅ CORRECT: Always use TypeFormatter for consistent results
var formatted = TypeFormatter.FormatType(type, TypeQualifications.FullyQualified);

❌ Hardcoding Type Names

❌ Hardcoding Type Names

// ❌ WRONG: Hardcoded type name
var typeName = "ECommerce.Contracts.Events.ProductCreatedEvent, ECommerce.Contracts";

// ✅ CORRECT: Use TypeFormatter
var typeName = TypeFormatter.FormatType(
    typeof(ProductCreatedEvent),
    TypeQualifications.FullyQualified
);

❌ Ignoring Culture in Manual Formatting

❌ Ignoring Culture in Manual Formatting

// ❌ WRONG: Culture-dependent formatting
var version = type.Assembly.GetName().Version;
var formatted = $"Version={version}"; // May use locale-specific format

// ✅ CORRECT: Use TypeFormatter with InvariantCulture
var formatted = TypeFormatter.FormatType(type, TypeQualifications.FullyQualifiedWithVersion);

Don't confuse TypeFormatter (the flags-based utility above) with TypeNameFormatter (Whizbang.Core), the fixed-format helper the runtime uses to write and match the type-name strings persisted in the database. It exposes exactly two canonical forms, and every writer of a given column goes through the matching one so the strings are byte-identical across drivers and generators:

Method Output Used for
TypeNameFormatter.Format(type) "Namespace.Outer+Nested, Assembly" (assembly-qualified) wh_event_store.event_type, wh_outbox/wh_inbox.message_type, wh_message_associations, message routing, JSON discriminators
TypeNameFormatter.FormatClrTypeName(type) "Namespace.Outer+Nested" (no assembly) wh_event_store.aggregate_type, wh_message_type_registry.clr_type_name, wh_perspective_registry.clr_type_name

Both are based on Type.FullName, so they use + to separate a nested type from its declaring type (unlike TypeFormatter, which uses Type.Name — see Nested Types above). The source-generator mirrors are TypeNameUtilities.FormatTypeNameForRuntime (assembly-qualified) and TypeNameUtilities.BuildClrTypeName (no assembly), so compile-time registration and runtime lookup always agree. Read paths normalize an inbound assembly-qualified name (stripping Version/Culture/PublicKeyToken) via EventTypeMatchingHelper.NormalizeTypeName before comparing, so the short Format form matches every variant. Existing rows written with the older C#-display (.-nested) form are normalized by migration 063_NormalizeClrTypeNamesV2 — see Migration Tracking → Data Migrations.

See Also