diff --git a/.gitignore b/.gitignore
index 3100a06..1a5096a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -660,3 +660,4 @@ sketch
BenchmarkDotNet.Artifacts/
.tools/purview-build/**
+src/tests/cross-platform/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f323841..d272ab1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,7 +11,7 @@
5.9.05.9.01.68.4
- 1.0.0-prerelease.42
+ 1.0.0-prerelease.4410.0.12
diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md
index 28c26a8..15b6563 100644
--- a/docs/wiki/AspNetCore-Integration.md
+++ b/docs/wiki/AspNetCore-Integration.md
@@ -101,16 +101,32 @@ public static partial class ConcurrentErrorType
Code: "aggregate_save_failed",
Description: "The aggregate could not be saved.",
HttpStatus: StatusCodes.Status409Conflict,
- MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save")
- {
- Parameters = ["AggregateId", "AggregateType"]
- };
+ MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save",
+ Parameters:
+ [
+ new("AggregateId", typeof(string)),
+ new("AggregateType", typeof(string))
+ ]);
}
// Register once at startup:
ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
```
+Parameters can also be declared with the `ErrorType.Param("Name")` helper instead of an explicit
+`typeof(...)`:
+
+```csharp
+Parameters:
+[
+ new("AggregateId", typeof(string)),
+ ErrorType.Param("AggregateType")
+]
+```
+
+Both the bundled `ZODSASP001` analyzer and the source generator treat `ErrorType.Param` entries
+exactly like any other declared parameter, so placeholder checking and helper generation are unchanged.
+
When an error carries that code, the response status, title, and message are derived automatically:
```csharp
@@ -132,10 +148,11 @@ throw new ZodException([
Marking the field with `[ErrorType]` and the containing class `partial` lets the bundled source
generator turn each field into strongly typed static helpers. For the field above it generates
`ConcurrentErrorType.CreateSaveFailed(...)` and `ConcurrentErrorType.ThrowSaveFailed(...)` with one
-named parameter per entry in `Parameters`:
+strongly typed parameter per entry in `Parameters` (the declared `typeof(...)` type, or the
+`ErrorType.Param` generic type argument):
```csharp
-// Returns a ValidationError with the code, the formatted message, and the named parameters.
+// Returns a ValidationError with the code, the formatted message, and the typed parameters.
var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice");
// Throws a ZodException carrying the same ValidationError.
@@ -152,7 +169,8 @@ var error = ConcurrentErrorType.CreateSaveFailed(
inclusive: true);
```
-The generated `Create` builds the `parameters` dictionary and sets the message from
+The generated `Create` builds a typed `ErrorTypeParameters` instance (validated against the declared
+parameter types and exposed through `ValidationError.Parameters`) and sets the message from
`ErrorType.FormatMessage`, so `error.Message` already reads
`Aggregate 'agg-123' (of type Invoice) failed to save` and mapping through the registry produces the
`409 Conflict` response described below. The analyzers `ZODSASP001`/`ZODSASP002`/`ZODSASP003`
diff --git a/package.json b/package.json
index 7e37c75..e7ae7ef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
- "version": "2.0.0-prerelease.11",
+ "version": "2.0.0-prerelease.12",
"private": true,
"license": "MIT",
"author": {
diff --git a/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs
index bdb82fd..fe331f2 100644
--- a/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs
+++ b/src/src/AspNetCore.Analyzers/ErrorTypeMessageFormatAnalyzer.cs
@@ -116,6 +116,17 @@ static void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSy
///
static HashSet? GetDeclaredParameters(IObjectCreationOperation creation)
{
+ // `Parameters` may be supplied as a constructor argument (the canonical form) or assigned
+ // in an object initializer.
+ foreach (var argument in creation.Arguments)
+ {
+ if (argument.ArgumentKind != ArgumentKind.Explicit)
+ continue;
+
+ if (argument.Parameter?.Name == "Parameters")
+ return ExtractStrings(argument.Value);
+ }
+
if (creation.Initializer is null)
return [];
@@ -143,10 +154,7 @@ initializer is ISimpleAssignmentOperation
{
HashSet names = new(StringComparer.Ordinal);
foreach (var element in array.Initializer.ElementValues)
- {
- if (UnwrapConversion(element).ConstantValue.Value is string value)
- names.Add(value);
- }
+ AddParameterName(element, names);
return names;
}
@@ -155,13 +163,7 @@ initializer is ISimpleAssignmentOperation
{
HashSet names = new(StringComparer.Ordinal);
foreach (var element in collection.Elements)
- {
- if (element is ISpreadOperation)
- continue;
-
- if (UnwrapConversion(element).ConstantValue.Value is string value)
- names.Add(value);
- }
+ AddParameterName(element, names);
return names;
}
@@ -171,6 +173,38 @@ initializer is ISimpleAssignmentOperation
}
}
+ static void AddParameterName(IOperation element, HashSet names)
+ {
+ if (element is ISpreadOperation)
+ return;
+
+ // Target-typed `new(...)` collection elements arrive wrapped in an implicit conversion.
+ element = UnwrapConversion(element);
+
+ // A declared parameter is `new ErrorTypeParameter("OrderId", typeof(string))`; the name is
+ // the first constructor argument.
+ if (
+ element is IObjectCreationOperation creation
+ && creation.Arguments.Length > 0
+ && creation.Arguments[0] is { Parameter.Name: "Name" } nameArgument
+ && UnwrapConversion(nameArgument.Value).ConstantValue.Value is string name
+ )
+ names.Add(name);
+ else if (
+ element is IInvocationOperation { TargetMethod.Name: "Param" } invocation
+ && IsErrorTypeParam(invocation.TargetMethod)
+ && invocation.Arguments.Length > 0
+ && UnwrapConversion(invocation.Arguments[0].Value).ConstantValue.Value is string paramName
+ )
+ names.Add(paramName);
+ else if (element.ConstantValue.Value is string legacy)
+ names.Add(legacy);
+ }
+
+ static bool IsErrorTypeParam(IMethodSymbol method) =>
+ method.ContainingType?.Name == "ErrorType"
+ && method.ContainingType?.ContainingNamespace?.ToDisplayString() == "ZodSharp.AspNetCore";
+
static IOperation UnwrapConversion(IOperation operation) =>
operation is IConversionOperation { IsImplicit: true } conversion
? UnwrapConversion(conversion.Operand)
diff --git a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs
index 8d2a2c5..9ab5b76 100644
--- a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs
+++ b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.BuildSource.cs
@@ -1,5 +1,4 @@
using System.Collections.Immutable;
-using ZodSharp.AspNetCore.SourceGenerators.Helpers;
using ZodSharp.AspNetCore.SourceGenerators.Models;
namespace ZodSharp.AspNetCore.SourceGenerators;
@@ -9,7 +8,7 @@ partial class ErrorTypeGenerator
static void Emit(CodeWriter writer, ErrorTypeFieldModel model)
{
var parameterNames = BuildParameterNames(model.Parameters);
- var parameters = BuildMethodParameters(parameterNames, writer);
+ var parameters = BuildMethodParameters(model, parameterNames, writer);
writer
.AutoGeneratedHeader()
@@ -22,7 +21,7 @@ static void Emit(CodeWriter writer, ErrorTypeFieldModel model)
{
using (
writer.ClassScope(
- new TypeDeclarationOptions(new TypeIdentity(model.ClassName, model.Namespace), model.Accessibility)
+ new(new(model.ClassName, model.Namespace), model.Accessibility)
{
IsPartial = true,
IsStatic = model.IsStatic,
@@ -46,14 +45,14 @@ static void EmitCreateMethod(
ImmutableArray parameters
)
{
- var dictionaryName = BuildDictionaryName(parameterNames);
+ var parametersName = BuildDictionaryName(parameterNames);
writer
.XmlSummary("Creates a ValidationError for this error type.")
.Method(
- new MethodDeclarationOptions(
+ new(
"Create" + PascalCase(model.FieldName),
- TypeLibrary.Core.ValidationError.AsTypeReference(),
+ TypeLibrary.ZodSharp.Core.ValidationError.AsTypeReference(),
TypeDeclarationAccessibility.Public
)
{
@@ -62,21 +61,23 @@ ImmutableArray parameters
},
method =>
{
+ method.Assignment("ErrorType", "errorType", model.FieldName);
+ method.Line();
+
method.OpenDelimitedBlock(
- $"global::System.Collections.Generic.Dictionary {dictionaryName} = new({model.Parameters.Count})",
+ $"var {parametersName} = global::ZodSharp.Core.ErrorTypeParameters.Create(errorType.Parameters, new global::System.Collections.Generic.Dictionary",
"{",
- "};",
+ "});",
body =>
{
for (var i = 0; i < model.Parameters.Count; i++)
- body.Line($"[{model.Parameters[i].Surround()}] = {parameterNames[i]},");
+ body.Line($"[{model.Parameters[i].Name.Surround()}] = {parameterNames[i]},");
}
);
method.Line();
- method.Assignment("ErrorType", "errorType", model.FieldName);
method.Return(
- $"ValidationError.Create(errorType.Code, errorType.FormatMessage({dictionaryName}), path is null ? [] : [.. path], {dictionaryName}, origin, minimum, maximum, inclusive)"
+ $"ValidationError.Create(errorType.Code, errorType.FormatMessage({parametersName}), path is null ? [] : [.. path], {parametersName}, origin, minimum, maximum, inclusive)"
);
}
);
@@ -92,10 +93,10 @@ ImmutableArray parameters
writer
.XmlSummary("Throws a ZodException for this error type.")
.Method(
- new MethodDeclarationOptions("Throw" + PascalCase(model.FieldName), TypeDeclarationAccessibility.Public)
+ new("Throw" + PascalCase(model.FieldName), TypeDeclarationAccessibility.Public)
{
IsStatic = true,
- Attributes = [new AttributeDeclarationOptions(TypeLibrary.DoesNotReturnAttribute)],
+ Attributes = [new(TypeLibrary.System.Diagnostics.CodeAnalysis.DoesNotReturnAttribute)],
Parameters = parameters,
},
method =>
@@ -115,25 +116,35 @@ ImmutableArray parameters
}
static ImmutableArray BuildMethodParameters(
+ ErrorTypeFieldModel model,
ImmutableArray parameterNames,
CodeWriter writer
)
{
- var objectType = PurviewTypeLibrary.System.Object.AsTypeReference().Nullable(writer);
var stringArrayType = PurviewTypeLibrary.System.String.AsTypeReference().MakeArray().Nullable(writer);
var stringType = PurviewTypeLibrary.System.String.AsTypeReference().Nullable(writer);
var intType = PurviewTypeLibrary.System.Int32.AsTypeReference().Nullable(writer);
var boolType = PurviewTypeLibrary.System.Boolean.AsTypeReference().Nullable(writer);
var builder = ImmutableArray.CreateBuilder(parameterNames.Length + 5);
- foreach (var name in parameterNames)
- builder.Add(new ParameterDeclarationOptions(name, objectType));
+ for (var i = 0; i < model.Parameters.Count; i++)
+ {
+ var parameter = model.Parameters[i];
+ var reference = parameter.Type.AsTypeReference();
+ if (parameter.IsNullable)
+ reference = reference.Nullable(writer);
+
+ if (parameter.ArrayRank > 0)
+ reference = reference.MakeArray(parameter.ArrayRank);
+
+ builder.Add(new ParameterDeclarationOptions(parameterNames[i], reference));
+ }
- builder.Add(new ParameterDeclarationOptions("path", stringArrayType) { DefaultValue = "null" });
- builder.Add(new ParameterDeclarationOptions("origin", stringType) { DefaultValue = "null" });
- builder.Add(new ParameterDeclarationOptions("minimum", intType) { DefaultValue = "null" });
- builder.Add(new ParameterDeclarationOptions("maximum", intType) { DefaultValue = "null" });
- builder.Add(new ParameterDeclarationOptions("inclusive", boolType) { DefaultValue = "null" });
+ builder.Add(new("path", stringArrayType) { DefaultValue = "null" });
+ builder.Add(new("origin", stringType) { DefaultValue = "null" });
+ builder.Add(new("minimum", intType) { DefaultValue = "null" });
+ builder.Add(new("maximum", intType) { DefaultValue = "null" });
+ builder.Add(new("inclusive", boolType) { DefaultValue = "null" });
return builder.ToImmutable();
}
@@ -142,14 +153,14 @@ CodeWriter writer
/// Camel-cases the declared parameter names while avoiding collisions with the reserved metadata
/// parameter names (path, origin, minimum, maximum, inclusive).
///
- static ImmutableArray BuildParameterNames(EquatableArray declaredNames)
+ static ImmutableArray BuildParameterNames(EquatableArray declaredParameters)
{
HashSet used = ["path", "origin", "minimum", "maximum", "inclusive", "parameters"];
- var builder = ImmutableArray.CreateBuilder(declaredNames.Count);
- foreach (var declaredName in declaredNames)
+ var builder = ImmutableArray.CreateBuilder(declaredParameters.Count);
+ foreach (var parameter in declaredParameters)
{
- var candidate = CamelCase(declaredName);
+ var candidate = CamelCase(parameter.Name);
while (!used.Add(candidate))
candidate += "Value";
diff --git a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs
index a674861..782af49 100644
--- a/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs
+++ b/src/src/AspNetCore.SourceGenerators/ErrorTypeGenerator.cs
@@ -35,7 +35,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
var errorTypeFields = IncrementalPipeline.ForAttributeWithMetadataName(
context,
- TypeLibrary.AspNetCore.ErrorTypeAttribute,
+ TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute,
predicate: static (node, _) => node is VariableDeclaratorSyntax,
transform: static (attributeContext, cancellationToken) =>
ErrorTypeGeneratorLibrary.CreateModel(attributeContext, cancellationToken),
@@ -56,7 +56,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
if (model.Context.Settings.IsSourceGeneratorDisabled)
return;
- if (!model.Context.Capabilities.HasErrorType)
+ if (
+ !model.Context.Capabilities.HasErrorType
+ || !model.Context.Capabilities.HasErrorTypeParameter
+ || !model.Context.Capabilities.HasErrorTypeParameters
+ )
return;
foreach (var result in model.Fields)
@@ -94,7 +98,7 @@ static SourceText ErrorTypeAttributeSource()
{
CodeWriter writer = new(GenerationSettings.Create());
- writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.AspNetCore.ErrorTypeAttribute);
+ writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute);
writer
.XmlSummary(
@@ -102,7 +106,7 @@ static SourceText ErrorTypeAttributeSource()
"When applied to a static ErrorType field in a partial class, Create/Throw helpers are generated at compile time."
)
.AttributeClass(
- new TypeDeclarationOptions(TypeLibrary.AspNetCore.ErrorTypeAttribute),
+ new TypeDeclarationOptions(TypeLibrary.ZodSharp.AspNetCore.ErrorTypeAttribute),
AttributeTargets.Field,
static _ => { },
inherited: false,
diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs
index 3f53911..1083433 100644
--- a/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs
+++ b/src/src/AspNetCore.SourceGenerators/Helpers/DiagnosticLibrary.cs
@@ -21,7 +21,7 @@ static class DiagnosticLibrary
public static readonly DiagnosticDescriptor InvalidParameters = new(
id: "ZODSASP101",
title: "Unable to extract ErrorType parameters",
- messageFormat: "The Parameters of ErrorType field '{0}' in '{1}' could not be extracted; only string collection literals are supported",
+ messageFormat: "The Parameters of ErrorType field '{0}' in '{1}' could not be extracted; only ErrorTypeParameter collection literals with a constant name and typeof, or ErrorType.Param invocations, are supported",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs
index 0600149..28abf58 100644
--- a/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs
+++ b/src/src/AspNetCore.SourceGenerators/Helpers/ErrorTypeGeneratorLibrary.cs
@@ -76,7 +76,7 @@ classDeclaration is null
containing.IsAbstract && !containing.IsStatic,
containing.IsSealed && !containing.IsStatic,
field.Name,
- new EquatableArray(parameters)
+ new EquatableArray(parameters)
)
);
}
@@ -85,26 +85,20 @@ classDeclaration is null
/// Extracts the declared Parameters from the field initializer. Returns null
/// when a Parameters member is present but cannot be statically analysed.
///
- static ImmutableArray? ExtractParameters(
+ static ImmutableArray? ExtractParameters(
ExpressionSyntax? initializer,
GeneratorAttributeSyntaxContext context,
CancellationToken cancellationToken
)
{
- if (initializer is null)
+ if (initializer is not BaseObjectCreationExpressionSyntax creation)
return [];
- var objectInitializer = initializer switch
- {
- ObjectCreationExpressionSyntax objectCreation => objectCreation.Initializer,
- ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.Initializer,
- _ => null,
- };
-
- if (objectInitializer is null)
- return [];
-
- var parametersExpression = FindParametersExpression(objectInitializer);
+ // `Parameters` may be supplied as a constructor argument (the canonical form) or assigned
+ // in an object initializer.
+ var parametersExpression =
+ FindParametersArgument(creation, context, cancellationToken)?.Expression
+ ?? FindParametersExpression(creation.Initializer);
if (parametersExpression is null)
return [];
@@ -112,19 +106,247 @@ CancellationToken cancellationToken
if (elements is null)
return null;
- var builder = ImmutableArray.CreateBuilder();
+ var builder = ImmutableArray.CreateBuilder();
foreach (var element in elements)
{
- var constant = context.SemanticModel.GetConstantValue(element, cancellationToken);
- if (constant.HasValue && constant.Value is string value)
- builder.Add(value);
+ if (!TryExtractParameter(element, context, cancellationToken, out var parameter))
+ return null;
+
+ builder.Add(parameter);
}
return builder.ToImmutable();
}
- static ExpressionSyntax? FindParametersExpression(InitializerExpressionSyntax objectInitializer)
+ ///
+ /// Returns the constructor argument bound to the Parameters parameter, or null
+ /// when none is supplied. Matches both the named (Parameters:) and positional forms.
+ ///
+ static ArgumentSyntax? FindParametersArgument(
+ BaseObjectCreationExpressionSyntax creation,
+ GeneratorAttributeSyntaxContext context,
+ CancellationToken cancellationToken
+ )
+ {
+ var argumentList = creation.ArgumentList;
+ if (argumentList is null)
+ return null;
+
+ if (context.SemanticModel.GetSymbolInfo(creation, cancellationToken).Symbol is not IMethodSymbol constructor)
+ return null;
+
+ var positionalIndex = 0;
+ foreach (var argument in argumentList.Arguments)
+ {
+ if (argument.NameColon is null)
+ {
+ if (
+ positionalIndex < constructor.Parameters.Length
+ && constructor.Parameters[positionalIndex] is { Name: "Parameters" }
+ )
+ return argument;
+
+ positionalIndex++;
+ continue;
+ }
+
+ foreach (var parameter in constructor.Parameters)
+ {
+ if (parameter.Name == argument.NameColon.Name.Identifier.ValueText && parameter.Name == "Parameters")
+ return argument;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses a single parameter declaration into a value-equatable .
+ /// Supports new ErrorTypeParameter("Name", typeof(T)), target-typed
+ /// new("Name", typeof(T)), and ErrorType.Param<T>("Name"). Returns
+ /// false when the element is not a statically-analysable declaration.
+ ///
+ static bool TryExtractParameter(
+ ExpressionSyntax element,
+ GeneratorAttributeSyntaxContext context,
+ CancellationToken cancellationToken,
+ out ErrorTypeParameterModel parameter
+ )
+ {
+ if (!TryExtractParameterNameAndType(element, context, cancellationToken, out var name, out var type))
+ {
+ parameter = default;
+ return false;
+ }
+
+ if (!TryGetParameterType(type, out var identity, out var isNullable, out var arrayRank))
+ {
+ parameter = default;
+ return false;
+ }
+
+ parameter = new ErrorTypeParameterModel(name, identity, isNullable, arrayRank);
+ return true;
+ }
+
+ ///
+ /// Resolves the declared name and type for a recognised parameter declaration form.
+ ///
+ static bool TryExtractParameterNameAndType(
+ ExpressionSyntax element,
+ GeneratorAttributeSyntaxContext context,
+ CancellationToken cancellationToken,
+ out string name,
+ out ITypeSymbol type
+ )
+ {
+ switch (element)
+ {
+ case ObjectCreationExpressionSyntax { ArgumentList: { } named } creation
+ when IsErrorTypeParameter(creation.Type, context):
+ return TryExtractConstructorArguments(named.Arguments, context, cancellationToken, out name, out type);
+
+ case ImplicitObjectCreationExpressionSyntax { ArgumentList: { } implicitNamed }:
+ return TryExtractConstructorArguments(
+ implicitNamed.Arguments,
+ context,
+ cancellationToken,
+ out name,
+ out type
+ );
+
+ case InvocationExpressionSyntax invocation:
+ return TryExtractParamInvocation(invocation, context, cancellationToken, out name, out type);
+
+ default:
+ name = null!;
+ type = null!;
+ return false;
+ }
+ }
+
+ ///
+ /// Resolves new ErrorTypeParameter("Name", typeof(T)) from its constructor arguments.
+ ///
+ static bool TryExtractConstructorArguments(
+ SeparatedSyntaxList arguments,
+ GeneratorAttributeSyntaxContext context,
+ CancellationToken cancellationToken,
+ out string name,
+ out ITypeSymbol type
+ )
+ {
+ name = null!;
+ type = null!;
+
+ if (arguments is not { Count: >= 2 } args)
+ return false;
+
+ var nameConstant = context.SemanticModel.GetConstantValue(args[0].Expression, cancellationToken);
+ if (!nameConstant.HasValue || nameConstant.Value is not string nameValue)
+ return false;
+
+ if (args[1].Expression is not TypeOfExpressionSyntax typeOf)
+ return false;
+
+ var typeInfo = context.SemanticModel.GetTypeInfo(typeOf.Type, cancellationToken);
+ if (typeInfo.Type is null)
+ return false;
+
+ name = nameValue;
+ type = typeInfo.Type;
+ return true;
+ }
+
+ ///
+ /// Resolves ErrorType.Param<T>("Name") from its generic type argument.
+ ///
+ static bool TryExtractParamInvocation(
+ InvocationExpressionSyntax invocation,
+ GeneratorAttributeSyntaxContext context,
+ CancellationToken cancellationToken,
+ out string name,
+ out ITypeSymbol type
+ )
+ {
+ name = null!;
+ type = null!;
+
+ if (context.SemanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not IMethodSymbol method)
+ return false;
+
+ var errorType = context.SemanticModel.Compilation.GetTypeByMetadataName(ErrorTypeMetadataName);
+ if (
+ errorType is null
+ || method.Name != "Param"
+ || !SymbolEqualityComparer.Default.Equals(method.ContainingType, errorType)
+ )
+ return false;
+
+ if (invocation.ArgumentList.Arguments is not { Count: >= 1 } args)
+ return false;
+
+ var nameConstant = context.SemanticModel.GetConstantValue(args[0].Expression, cancellationToken);
+ if (!nameConstant.HasValue || nameConstant.Value is not string nameValue)
+ return false;
+
+ if (method.TypeArguments.Length != 1)
+ return false;
+
+ name = nameValue;
+ type = method.TypeArguments[0];
+ return true;
+ }
+
+ static bool IsErrorTypeParameter(TypeSyntax typeSyntax, GeneratorAttributeSyntaxContext context)
{
+ var type = context.SemanticModel.GetTypeInfo(typeSyntax).Type as INamedTypeSymbol;
+ return type is not null
+ && type.Name == "ErrorTypeParameter"
+ && type.ContainingNamespace?.ToDisplayString() == "ZodSharp.Core";
+ }
+
+ ///
+ /// Resolves a typeof(...) argument into the value-equatable components needed to emit its
+ /// type reference: the underlying (nullable value types unwrapped),
+ /// whether it is Nullable<T>, and the array rank. Returns false when the
+ /// symbol cannot be represented.
+ ///
+ static bool TryGetParameterType(ITypeSymbol type, out TypeIdentity identity, out bool isNullable, out int arrayRank)
+ {
+ isNullable = false;
+ arrayRank = 0;
+
+ if (type is IArrayTypeSymbol arrayType)
+ {
+ if (!TryGetParameterType(arrayType.ElementType, out identity, out isNullable, out _))
+ return false;
+
+ arrayRank = arrayType.Rank;
+ return true;
+ }
+
+ if (
+ type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable
+ && nullable.TypeArguments[0] is INamedTypeSymbol underlying
+ )
+ {
+ isNullable = true;
+ type = underlying;
+ }
+
+ if (!TypeIdentity.TryCreate(type, out identity))
+ return false;
+
+ // We don't support nullable reference types because they are not represented in the emitted
+ return true;
+ }
+
+ static ExpressionSyntax? FindParametersExpression(InitializerExpressionSyntax? objectInitializer)
+ {
+ if (objectInitializer is null)
+ return null;
+
foreach (var element in objectInitializer.Expressions)
{
if (
@@ -150,6 +372,9 @@ CancellationToken cancellationToken
.Select(static element => element.Expression),
ImplicitArrayCreationExpressionSyntax implicitArray => implicitArray.Initializer.Expressions,
ArrayCreationExpressionSyntax array => array.Initializer?.Expressions ?? [],
+ ObjectCreationExpressionSyntax { Initializer: { } initializer } => initializer.Expressions,
+ ImplicitObjectCreationExpressionSyntax { Initializer: { } implicitInitializer } =>
+ implicitInitializer.Expressions,
_ => null,
};
}
diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs
deleted file mode 100644
index 2eab0f0..0000000
--- a/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibrary.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-namespace ZodSharp.AspNetCore.SourceGenerators.Helpers;
-
-///
-/// Type identities used by the when emitting source.
-///
-static class TypeLibrary
-{
- public static class AspNetCore
- {
- public static readonly TypeIdentity ErrorType = new("ErrorType", "ZodSharp.AspNetCore");
-
- public static readonly TypeIdentity ErrorTypeAttribute = new("ErrorTypeAttribute", "ZodSharp.AspNetCore");
- }
-
- public static class Core
- {
- public static readonly TypeIdentity ValidationError = new("ValidationError", "ZodSharp.Core");
-
- public static readonly TypeIdentity ZodException = new("ZodException", "ZodSharp.Core");
- }
-
- public static readonly TypeIdentity DoesNotReturnAttribute = new(
- "DoesNotReturnAttribute",
- "System.Diagnostics.CodeAnalysis"
- );
-}
diff --git a/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibraryGenerator.cs b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibraryGenerator.cs
new file mode 100644
index 0000000..c4cc5fd
--- /dev/null
+++ b/src/src/AspNetCore.SourceGenerators/Helpers/TypeLibraryGenerator.cs
@@ -0,0 +1,20 @@
+namespace ZodSharp.AspNetCore.SourceGenerators.Helpers;
+
+[GenerateTypeLibrary]
+static partial class TypeLibraryGenerator
+{
+ [TypeRef("ZodSharp.AspNetCore")]
+ static readonly TypeIdentity ErrorType = default;
+
+ [TypeRef("ZodSharp.AspNetCore")]
+ static readonly TypeIdentity ErrorTypeAttribute = default;
+
+ [TypeRef("ZodSharp.Core")]
+ static readonly TypeIdentity ValidationError = default;
+
+ [TypeRef("ZodSharp.Core")]
+ static readonly TypeIdentity ZodException = default;
+
+ [TypeRef("System.Diagnostics.CodeAnalysis")]
+ static readonly TypeIdentity DoesNotReturnAttribute = default;
+}
diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs
index 95b3266..182e4b9 100644
--- a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs
+++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeFieldModel.cs
@@ -12,7 +12,7 @@ sealed record ErrorTypeFieldModel(
bool IsAbstract,
bool IsSealed,
string FieldName,
- EquatableArray Parameters
+ EquatableArray Parameters
)
{
///
diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs
index 2466354..d968ad3 100644
--- a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs
+++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeGeneratorCapabilities.cs
@@ -6,10 +6,19 @@ namespace ZodSharp.AspNetCore.SourceGenerators.Models;
/// Value-equatable capability facts for the . No Roslyn
/// objects are retained in this model.
///
-sealed record ErrorTypeGeneratorCapabilities(bool HasErrorType) : IGenerationCapabilities
+sealed record ErrorTypeGeneratorCapabilities(bool HasErrorType, bool HasErrorTypeParameter, bool HasErrorTypeParameters)
+ : IGenerationCapabilities
{
public static ErrorTypeGeneratorCapabilities Create(Compilation compilation) =>
- new(compilation.GetTypeByMetadataName(ErrorTypeMetadataName) is not null);
+ new(
+ compilation.GetTypeByMetadataName(ErrorTypeMetadataName) is not null,
+ compilation.GetTypeByMetadataName(ErrorTypeParameterMetadataName) is not null,
+ compilation.GetTypeByMetadataName(ErrorTypeParametersMetadataName) is not null
+ );
const string ErrorTypeMetadataName = "ZodSharp.AspNetCore.ErrorType";
+
+ const string ErrorTypeParameterMetadataName = "ZodSharp.Core.ErrorTypeParameter";
+
+ const string ErrorTypeParametersMetadataName = "ZodSharp.Core.ErrorTypeParameters";
}
diff --git a/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeParameterModel.cs b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeParameterModel.cs
new file mode 100644
index 0000000..d2aff59
--- /dev/null
+++ b/src/src/AspNetCore.SourceGenerators/Models/ErrorTypeParameterModel.cs
@@ -0,0 +1,13 @@
+namespace ZodSharp.AspNetCore.SourceGenerators.Models;
+
+///
+/// Value-equatable pipeline model describing one declared ErrorType parameter: the
+/// placeholder name and the components needed to emit its strongly typed parameter type. All
+/// Roslyn objects are removed before this point; array and nullable modifiers are applied by the
+/// emitter so the target compilation's nullable context is honoured.
+///
+/// The declared placeholder name.
+/// The underlying type identity (nullable value types are unwrapped).
+/// Whether the type is Nullable<T>.
+/// The array rank, or 0 when the type is not an array.
+readonly record struct ErrorTypeParameterModel(string Name, TypeIdentity Type, bool IsNullable, int ArrayRank);
diff --git a/src/src/AspNetCore/ErrorType.cs b/src/src/AspNetCore/ErrorType.cs
index 98f86f8..32c5219 100644
--- a/src/src/AspNetCore/ErrorType.cs
+++ b/src/src/AspNetCore/ErrorType.cs
@@ -1,12 +1,13 @@
using System.Globalization;
using System.Text;
+using ZodSharp.Core;
namespace ZodSharp.AspNetCore;
///
/// Defines a user-facing error type that maps a validation error code to an HTTP status code,
/// optional title/type metadata, and an optional message template whose named placeholders are
-/// substituted from the error's .
+/// substituted from the error's .
///
/// The validation error code this error type maps to.
/// An optional human-readable description of the error.
@@ -19,9 +20,13 @@ namespace ZodSharp.AspNetCore;
///
/// An optional message template with named placeholders (for example
/// "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save"). Placeholders are
-/// substituted from ; placeholders without a
+/// substituted from ; placeholders without a
/// matching value are left as-is so templating gaps stay visible.
///
+///
+/// The named placeholders expected by
+/// together with their expected value types (for example new("AggregateId", typeof(string))).
+///
///
///
/// public static partial class ConcurrentErrorType
@@ -31,15 +36,17 @@ namespace ZodSharp.AspNetCore;
/// Code: "aggregate_save_failed",
/// Description: "The aggregate could not be saved.",
/// HttpStatus: StatusCodes.Status409Conflict,
-/// MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save")
-/// {
-/// Parameters = ["AggregateId", "AggregateType"]
-/// };
+/// MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save",
+/// Parameters:
+/// [
+/// new("AggregateId", typeof(string)),
+/// new("AggregateType", typeof(string))
+/// ]);
/// }
///
/// Marking the field with [ErrorType] and declaring the containing class partial
/// lets the bundled source generator add CreateSaveFailed/ThrowSaveFailed helpers
-/// with one named parameter per declared parameter.
+/// with one strongly typed parameter per declared parameter.
///
public sealed record ErrorType(
string Code,
@@ -47,13 +54,34 @@ public sealed record ErrorType(
int HttpStatus = Microsoft.AspNetCore.Http.StatusCodes.Status400BadRequest,
string? Title = null,
string? Type = null,
- string? MessageFormat = null
+ string? MessageFormat = null,
+ IReadOnlyList? Parameters = null
)
{
///
- /// The named placeholders expected by (for example "AggregateId").
+ /// Creates an error type parameter with the specified name and type.
+ ///
+ /// The type represented by the parameter.
+ /// The name of the parameter.
+ /// The created error type parameter.
+ public static ErrorTypeParameter Param(string name)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ return new(name, typeof(T));
+ }
+
+ ///
+ /// Formats using the supplied typed parameter values. Placeholders
+ /// that have no matching value are left as-is so templating gaps stay visible. When
+ /// is null, falls back to and then
+ /// to .
///
- public IReadOnlyList Parameters { get; init; } = [];
+ ///
+ /// The typed parameter values (for example the error's ).
+ ///
+ public string FormatMessage(ErrorTypeParameters? parameters) =>
+ FormatMessage((IReadOnlyDictionary?)parameters);
///
/// Formats using the supplied named parameter values. Placeholders
@@ -62,8 +90,7 @@ public sealed record ErrorType(
/// to .
///
///
- /// The named parameter values (for example the error's
- /// ).
+ /// The named parameter values (for example the error's ).
///
public string FormatMessage(IReadOnlyDictionary? parameters)
{
diff --git a/src/src/AspNetCore/ProblemDetailsExtensions.cs b/src/src/AspNetCore/ProblemDetailsExtensions.cs
index 437c0e8..b127aa7 100644
--- a/src/src/AspNetCore/ProblemDetailsExtensions.cs
+++ b/src/src/AspNetCore/ProblemDetailsExtensions.cs
@@ -163,5 +163,5 @@ public readonly record struct ValidationIssue
[System.Text.Json.Serialization.JsonIgnore(
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
)]
- public IReadOnlyDictionary? Parameters { get; init; }
+ public ErrorTypeParameters? Parameters { get; init; }
}
diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md
index 352eb46..6a57e15 100644
--- a/src/src/AspNetCore/Sdk/README.md
+++ b/src/src/AspNetCore/Sdk/README.md
@@ -61,15 +61,28 @@ public static partial class ConcurrentErrorType
Code: "aggregate_save_failed",
Description: "The aggregate could not be saved.",
HttpStatus: StatusCodes.Status409Conflict,
- MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save")
- {
- Parameters = ["AggregateId", "AggregateType"]
- };
+ MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save",
+ Parameters:
+ [
+ new("AggregateId", typeof(string)),
+ new("AggregateType", typeof(string))
+ ]);
}
ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed);
```
+Parameters can also be declared with the `ErrorType.Param("Name")` helper instead of an explicit
+`typeof(...)`; the analyzer and source generator handle both forms the same way:
+
+```csharp
+Parameters:
+[
+ new("AggregateId", typeof(string)),
+ ErrorType.Param("AggregateType")
+]
+```
+
A `ValidationError` carrying `parameters` such as `["AggregateId"] = "agg-123"` is then surfaced as a
`409 Conflict` response whose message reads `Aggregate 'agg-123' (of type Invoice) failed to save`. The
analyzer `ZODSASP001` (bundled with the package) warns when a `MessageFormat` placeholder is not declared
@@ -78,10 +91,11 @@ in `Parameters`.
### Generated `Create` / `Throw` helpers
Because the class above is `partial`, the bundled source generator adds strongly typed helpers derived
-from the declared `Parameters`:
+from the declared `Parameters` — each parameter is emitted with its declared `typeof(...)` type, or the
+`ErrorType.Param` generic type argument:
```csharp
-// ValidationError with the code, the formatted message, and the named parameters:
+// ValidationError with the code, the formatted message, and the typed parameters:
var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice");
// ZodException carrying that ValidationError:
@@ -98,6 +112,10 @@ var error = ConcurrentErrorType.CreateSaveFailed(
inclusive: true);
```
+The generated helpers construct a typed `ErrorTypeParameters` instance (exposed through
+`ValidationError.Parameters`) whose values are validated against the declared types and can be read back
+through `Get(name)`.
+
The analyzer `ZODSASP002` warns when an `ErrorType` field's containing class is not declared `partial`.
## Documentation
diff --git a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs
index df88e72..c34e1a8 100644
--- a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs
+++ b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs
@@ -61,7 +61,11 @@ public ProblemDetailsMappingPerformanceTests()
MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save"
)
{
- Parameters = ["AggregateId", "AggregateType"],
+ Parameters =
+ [
+ new ErrorTypeParameter("AggregateId", typeof(string)),
+ new ErrorTypeParameter("AggregateType", typeof(string)),
+ ],
}
);
diff --git a/src/src/ZodSharp/Core/ErrorTypeParameter.cs b/src/src/ZodSharp/Core/ErrorTypeParameter.cs
new file mode 100644
index 0000000..89597f2
--- /dev/null
+++ b/src/src/ZodSharp/Core/ErrorTypeParameter.cs
@@ -0,0 +1,9 @@
+namespace ZodSharp.Core;
+
+///
+/// Declares a named parameter of an ErrorType message template, together with the CLR
+/// the value is expected to have.
+///
+/// The named placeholder this parameter maps to.
+/// The expected value type (for example typeof(string)).
+public sealed record ErrorTypeParameter(string Name, Type Type);
diff --git a/src/src/ZodSharp/Core/ErrorTypeParameters.cs b/src/src/ZodSharp/Core/ErrorTypeParameters.cs
new file mode 100644
index 0000000..60e3407
--- /dev/null
+++ b/src/src/ZodSharp/Core/ErrorTypeParameters.cs
@@ -0,0 +1,155 @@
+using System.Collections;
+
+namespace ZodSharp.Core;
+
+///
+/// A typed, read-only set of error parameters. Carries the declared
+/// metadata alongside the values so consumers can read values
+/// through the strongly typed accessor and format messages with knowledge of
+/// each parameter's type. Implements so existing
+/// dictionary-based consumers keep working unchanged.
+///
+[System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Design",
+ "CA1710:Identifiers should have correct suffix",
+ Justification = "Real name"
+)]
+public sealed class ErrorTypeParameters : IReadOnlyDictionary
+{
+ static readonly IReadOnlyDictionary s_emptyDeclarations =
+ new Dictionary();
+
+ readonly IReadOnlyDictionary _values;
+ readonly IReadOnlyDictionary _declarations;
+
+ ErrorTypeParameters(
+ IReadOnlyDictionary values,
+ IReadOnlyDictionary declarations
+ )
+ {
+ _values = values;
+ _declarations = declarations;
+ }
+
+ ///
+ /// Creates a typed parameter set from the declared parameters and their values. Each value's
+ /// runtime type is validated against its declaration.
+ ///
+ /// The declared parameters (names and expected types).
+ /// The parameter values keyed by name.
+ /// A value's type does not match its declared type.
+ public static ErrorTypeParameters Create(
+ IReadOnlyList? declarations,
+ IEnumerable> values
+ )
+ {
+ ArgumentNullException.ThrowIfNull(values);
+
+ Dictionary declarationMap = new(StringComparer.Ordinal);
+ if (declarations is not null)
+ {
+ foreach (var declaration in declarations)
+ declarationMap[declaration.Name] = declaration;
+ }
+
+ Dictionary valueMap = new(StringComparer.Ordinal);
+ foreach (var pair in values)
+ {
+ if (
+ declarationMap.TryGetValue(pair.Key, out var declaration) && !IsCompatible(declaration.Type, pair.Value)
+ )
+ {
+ throw new ArgumentException(
+ $"Parameter '{pair.Key}' has value of type '{pair.Value?.GetType().FullName ?? "null"}' but was declared as '{declaration.Type.FullName}'.",
+ nameof(values)
+ );
+ }
+
+ valueMap[pair.Key] = pair.Value;
+ }
+
+ return new ErrorTypeParameters(valueMap, declarationMap);
+ }
+
+ ///
+ /// Creates a typed parameter set from values only, without declared types.
+ ///
+ /// The parameter values keyed by name.
+ public static ErrorTypeParameters Create(IEnumerable> values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+
+ Dictionary valueMap = new(StringComparer.Ordinal);
+ foreach (var pair in values)
+ valueMap[pair.Key] = pair.Value;
+
+ return new ErrorTypeParameters(valueMap, s_emptyDeclarations);
+ }
+
+ ///
+ /// Returns the value for cast to . Returns the
+ /// default value for when the value is absent, is null, does not
+ /// match the declared type, or is not assignable to .
+ ///
+ /// The parameter name.
+ public T? Get(string name)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+
+ if (!_values.TryGetValue(name, out var value))
+ return default;
+
+ if (value is null)
+ return default;
+
+ if (_declarations.TryGetValue(name, out var declaration) && !IsCompatible(declaration.Type, value))
+ return default;
+
+ // The value is compatible with the declared type (if any), but may not be assignable to T.
+ return value is T typed ? typed : default;
+ }
+
+ ///
+ /// Returns the declared type for , or null when the value was
+ /// created without declarations.
+ ///
+ /// The parameter name.
+ public Type? GetDeclaredType(string name)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+
+ return _declarations.TryGetValue(name, out var declaration) ? declaration.Type : null;
+ }
+
+ static bool IsCompatible(Type type, object? value)
+ {
+ if (value is null)
+ return !type.IsValueType || Nullable.GetUnderlyingType(type) is not null;
+
+ // The value is non-null, so check if it is assignable to the declared type.
+ return type.IsInstanceOfType(value);
+ }
+
+ ///
+ public object? this[string key] => _values[key];
+
+ ///
+ public IEnumerable Keys => _values.Keys;
+
+ ///
+ public IEnumerable