From 7faa613eafd3aef90497748dc36a67279a0da77a Mon Sep 17 00:00:00 2001
From: tvenclovas96_bigblackc <144885265+tvenclovas96@users.noreply.github.com>
Date: Sun, 8 Feb 2026 23:17:40 +0200
Subject: [PATCH] constraints
---
NewType.Generator/AliasAttributeSource.cs | 26 +++++++-
NewType.Generator/AliasCodeGenerator.cs | 65 ++++++++++++++++--
NewType.Generator/AliasGenerator.cs | 66 ++++++++++++++-----
NewType.Generator/AliasModel.cs | 19 ++++--
NewType.Generator/AliasModelExtractor.cs | 43 +++++++++---
NewType.Generator/AnalyzerReleases.Shipped.md | 3 +
.../AnalyzerReleases.Unshipped.md | 8 +++
NewType.Generator/NewType.Generator.csproj | 4 ++
NewType.Tests/ConstraintValidationTests.cs | 62 +++++++++++++++++
NewType.Tests/Types.cs | 14 +++-
10 files changed, 270 insertions(+), 40 deletions(-)
create mode 100644 NewType.Generator/AnalyzerReleases.Shipped.md
create mode 100644 NewType.Generator/AnalyzerReleases.Unshipped.md
create mode 100644 NewType.Tests/ConstraintValidationTests.cs
diff --git a/NewType.Generator/AliasAttributeSource.cs b/NewType.Generator/AliasAttributeSource.cs
index debf22e..dc04e74 100644
--- a/NewType.Generator/AliasAttributeSource.cs
+++ b/NewType.Generator/AliasAttributeSource.cs
@@ -35,7 +35,26 @@ internal enum NewtypeOptions
/// Suppress implicit conversions and constructor forwarding.
Opaque = NoImplicitConversions | NoConstructorForwarding,
}
-
+
+ ///
+ /// Controls which constraint-related features the newtype generator emits. If enabled,
+ /// will automatically call a user-defined 'bool IsValid(AliasedType value)' method on the newtype
+ /// to verify it is valid
+ ///
+ [global::System.Flags]
+ internal enum NewtypeConstraintOptions
+ {
+ /// Constraints disabled(default).
+ Disabled = 0,
+ /// Enable constraints. Debug builds only by default
+ Enabled = 1,
+ /// Include constraint code in release builds, if constraints are enabled
+ IncludeInRelease = 2,
+
+ /// Enable constraints and include in release builds.
+ ReleaseEnabled = Enabled | IncludeInRelease,
+ }
+
///
/// Marks a partial type as a type alias for the specified type.
/// The source generator will generate implicit conversions, operator forwarding,
@@ -54,7 +73,10 @@ public newtypeAttribute() { }
/// Controls which features the generator emits.
public NewtypeOptions Options { get; set; }
-
+
+ /// Controls which constraint features the generator emits.
+ public NewtypeConstraintOptions ConstraintOptions { get; set; }
+
///
/// Overrides the MethodImplOptions applied to generated members.
/// Default is .
diff --git a/NewType.Generator/AliasCodeGenerator.cs b/NewType.Generator/AliasCodeGenerator.cs
index cc75f93..ff9b5a2 100644
--- a/NewType.Generator/AliasCodeGenerator.cs
+++ b/NewType.Generator/AliasCodeGenerator.cs
@@ -18,6 +18,9 @@ internal class AliasCodeGenerator
private readonly AliasModel _model;
private readonly StringBuilder _sb = new();
+ const string SingleIndent = " ";
+
+
public AliasCodeGenerator(AliasModel model)
{
_model = model;
@@ -137,12 +140,28 @@ private void AppendField()
private void AppendConstructors()
{
- var indent = GetMemberIndent();
+ var memberIndent = GetMemberIndent();
// Constructor from aliased type (always emitted)
- _sb.AppendLine($"{indent}/// Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}.");
- AppendMethodImplAttribute(indent);
- _sb.AppendLine($"{indent}public {_model.TypeName}({_model.AliasedTypeFullName} value) => _value = value;");
+ _sb.AppendLine($"{memberIndent}/// Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}.");
+ AppendMethodImplAttribute(memberIndent);
+
+
+ if (_model.IncludeConstraints)
+ {
+ _sb.AppendLine($"{memberIndent}public {_model.TypeName}({_model.AliasedTypeFullName} value)");
+ _sb.Append(memberIndent).Append('{').AppendLine();
+
+ AppendConstraintChecker(SingleIndent, "value");
+ _sb.Append(SingleIndent).Append(SingleIndent).Append(SingleIndent).AppendLine("_value = value;");
+
+ _sb.Append(memberIndent).Append('}').AppendLine();
+ }
+ else
+ {
+ _sb.AppendLine($"{memberIndent}public {_model.TypeName}({_model.AliasedTypeFullName} value) => _value = value;");
+ }
+
_sb.AppendLine();
// Forward constructors from the aliased type (conditionally)
@@ -150,7 +169,7 @@ private void AppendConstructors()
{
foreach (var ctor in _model.ForwardedConstructors)
{
- AppendForwardedConstructor(indent, ctor);
+ AppendForwardedConstructor(memberIndent, ctor);
}
}
}
@@ -668,7 +687,22 @@ private void AppendForwardedConstructor(string indent, ConstructorInfo ctor)
_sb.AppendLine($"{indent}/// Forwards {_model.AliasedTypeMinimalName} constructor.");
AppendMethodImplAttribute(indent);
- _sb.AppendLine($"{indent}public {_model.TypeName}({parameters}) => _value = new {_model.AliasedTypeFullName}({arguments});");
+ if (_model.IncludeConstraints)
+ {
+ const string valueName = "newValue";
+ _sb.AppendLine($"{indent}public {_model.TypeName}({parameters})");
+ _sb.Append(indent).Append('{').AppendLine();
+ _sb.Append(indent).Append(SingleIndent).AppendLine($"var {valueName} = new {_model.AliasedTypeFullName}({arguments});");
+
+ AppendConstraintChecker(SingleIndent, valueName);
+ _sb.Append(SingleIndent).Append(SingleIndent).Append(SingleIndent).AppendLine($"_value = {valueName};");
+
+ _sb.Append(indent).Append('}').AppendLine();
+ }
+ else
+ {
+ _sb.AppendLine($"{indent}public {_model.TypeName}({parameters}) => _value = new {_model.AliasedTypeFullName}({arguments});");
+ }
_sb.AppendLine();
}
@@ -691,6 +725,23 @@ private void AppendMethodImplAttribute(string indent)
_sb.AppendLine(line);
}
+ private void AppendConstraintChecker(string indent, string valueName)
+ {
+ if (!_model.validValidationMethod) return;
+
+ if (_model.DebugOnlyConstraints)
+ _sb.AppendLine("#if DEBUG");
+
+ _sb.Append(indent).Append(indent).Append(indent)
+ .AppendLine($"if (!IsValid({valueName}))");
+ _sb.Append(indent).Append(indent).Append(indent).Append(indent)
+ .AppendLine($"throw new InvalidOperationException($\"Failed validation check when trying to create '{_model.TypeName}' with '{_model.AliasedTypeMinimalName}' value: {{{valueName}}}\");"); // we heard you like interpolation
+
+
+ if (_model.DebugOnlyConstraints)
+ _sb.AppendLine("#endif");
+ }
+
private static string FormatConstructorParameters(ConstructorInfo ctor)
{
return string.Join(", ", ctor.Parameters.Array.Select(p =>
@@ -728,7 +779,7 @@ private static string FormatConstructorArguments(ConstructorInfo ctor)
}));
}
- private string GetMemberIndent() => string.IsNullOrEmpty(_model.Namespace) ? " " : " ";
+ private string GetMemberIndent() => string.IsNullOrEmpty(_model.Namespace) ? SingleIndent : $"{SingleIndent}{SingleIndent}";
private static string? GetOperatorSymbol(string operatorName)
{
diff --git a/NewType.Generator/AliasGenerator.cs b/NewType.Generator/AliasGenerator.cs
index 684aa30..a1e7379 100644
--- a/NewType.Generator/AliasGenerator.cs
+++ b/NewType.Generator/AliasGenerator.cs
@@ -12,30 +12,42 @@ namespace newtype.generator;
[Generator(LanguageNames.CSharp)]
public class AliasGenerator : IIncrementalGenerator
{
+ private static readonly DiagnosticDescriptor MissingIsValidMethodDiagnostic =
+ new DiagnosticDescriptor(
+ id: "NEWTYPE001",
+ title: "Missing validation method",
+ messageFormat: $"Type '{{0}}' uses constraints but does not define a compatible validation method. Expected signature: 'bool {AliasModel.ConstraintValidationMethodSymbol}({{1}})'.",
+ category: "Unknown",
+ DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "A constraint-enabled wrapped type must define a validation method."
+ );
+
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Register the attribute source
- context.RegisterPostInitializationOutput(ctx => { ctx.AddSource("newtypeAttribute.g.cs", SourceText.From(NewtypeAttributeSource.Source, Encoding.UTF8)); });
+ context.RegisterPostInitializationOutput(ctx =>
+ {
+ ctx.AddSource("newtypeAttribute.g.cs", SourceText.From(NewtypeAttributeSource.Source, Encoding.UTF8));
+ });
// Pipeline for generic [newtype] attribute
- var genericPipeline = context.SyntaxProvider
+ IncrementalValuesProvider genericPipeline = context.SyntaxProvider
.ForAttributeWithMetadataName(
"newtype.newtypeAttribute`1",
predicate: static (node, _) => node is TypeDeclarationSyntax,
transform: static (ctx, _) => ExtractGenericModel(ctx))
- .Where(static model => model is not null)
- .Select(static (model, _) => model!.Value);
+ .Where(static model => model is not null)!;
context.RegisterSourceOutput(genericPipeline, static (spc, model) => GenerateAliasCode(spc, model));
// Pipeline for non-generic [newtype(typeof(T))] attribute
- var nonGenericPipeline = context.SyntaxProvider
+ IncrementalValuesProvider nonGenericPipeline = context.SyntaxProvider
.ForAttributeWithMetadataName(
"newtype.newtypeAttribute",
predicate: static (node, _) => node is TypeDeclarationSyntax,
transform: static (ctx, _) => ExtractNonGenericModel(ctx))
- .Where(static model => model is not null)
- .Select(static (model, _) => model!.Value);
+ .Where(static model => model is not null)!;
context.RegisterSourceOutput(nonGenericPipeline, static (spc, model) => GenerateAliasCode(spc, model));
}
@@ -45,14 +57,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
foreach (var attributeData in context.Attributes)
{
var attributeClass = attributeData.AttributeClass;
- if (attributeClass is {IsGenericType: true} &&
+ if (attributeClass is { IsGenericType: true } &&
attributeClass.TypeArguments.Length == 1)
{
var aliasedType = attributeClass.TypeArguments[0];
- var (options, methodImpl) = ExtractNamedArguments(attributeData);
- return AliasModelExtractor.Extract(context, aliasedType, options, methodImpl);
+ var options = ExtractNamedArguments(attributeData);
+ return AliasModelExtractor.Extract(context, aliasedType, options);
}
}
+
return null;
}
@@ -63,21 +76,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
if (attributeData.ConstructorArguments.Length > 0 &&
attributeData.ConstructorArguments[0].Value is ITypeSymbol aliasedType)
{
- var (options, methodImpl) = ExtractNamedArguments(attributeData);
- return AliasModelExtractor.Extract(context, aliasedType, options, methodImpl);
+ var options = ExtractNamedArguments(attributeData);
+
+ return AliasModelExtractor.Extract(context, aliasedType, options);
}
}
+
return null;
}
// Mirrors MethodImplOptions.AggressiveInlining — the generator can't reference
// the injected NewtypeOptions enum, and we want readable defaults.
private const int DefaultOptions = 0;
+ private const int DefaultConstraintOptions = 0;
private const int DefaultMethodImplAggressiveInlining = 256;
- private static (int options, int methodImpl) ExtractNamedArguments(AttributeData attributeData)
+ private static ExtractedOptions ExtractNamedArguments(
+ AttributeData attributeData)
{
int options = DefaultOptions;
+ int constraintOptions = DefaultConstraintOptions;
int methodImpl = DefaultMethodImplAggressiveInlining;
foreach (var arg in attributeData.NamedArguments)
@@ -87,23 +105,39 @@ private static (int options, int methodImpl) ExtractNamedArguments(AttributeData
case "Options":
options = (int)arg.Value.Value!;
break;
+ case "ConstraintOptions":
+ constraintOptions = (int)arg.Value.Value!;
+ break;
case "MethodImpl":
methodImpl = (int)arg.Value.Value!;
break;
}
}
- return (options, methodImpl);
+ return new ExtractedOptions(options, constraintOptions, methodImpl);
}
private static void GenerateAliasCode(
SourceProductionContext context,
AliasModel model)
{
+ if (model.IncludeConstraints && !model.validValidationMethod)
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ MissingIsValidMethodDiagnostic,
+ model.Location,
+ model.TypeName,
+ model.AliasedTypeFullName
+ ));
+
+ return;
+ }
+
var generator = new AliasCodeGenerator(model);
var source = generator.Generate();
-
+
var fileName = $"{model.TypeDisplayString.Replace(".", "_").Replace("<", "_").Replace(">", "_")}.g.cs";
context.AddSource(fileName, SourceText.From(source, Encoding.UTF8));
}
-}
+}
\ No newline at end of file
diff --git a/NewType.Generator/AliasModel.cs b/NewType.Generator/AliasModel.cs
index 2584186..7279467 100644
--- a/NewType.Generator/AliasModel.cs
+++ b/NewType.Generator/AliasModel.cs
@@ -8,7 +8,7 @@ namespace newtype.generator;
/// Fully-extracted, equatable model representing a newtype alias.
/// Contains only strings, bools, plain enums, and EquatableArrays — no Roslyn symbols.
///
-internal readonly record struct AliasModel(
+internal record AliasModel(
// Type being declared
string TypeName,
string Namespace,
@@ -16,8 +16,10 @@ internal readonly record struct AliasModel(
bool IsReadonly,
bool IsClass,
bool IsRecord,
- bool IsRecordStruct,
-
+
+ // Location for messages
+ Location? Location,
+
// Aliased type
string AliasedTypeFullName,
string AliasedTypeMinimalName,
@@ -41,6 +43,10 @@ internal readonly record struct AliasModel(
bool SuppressImplicitUnwrap,
bool SuppressConstructorForwarding,
int MethodImplValue,
+
+ bool IncludeConstraints,
+ bool DebugOnlyConstraints,
+ bool validValidationMethod,
// Members
EquatableArray BinaryOperators,
@@ -50,7 +56,12 @@ internal readonly record struct AliasModel(
EquatableArray InstanceProperties,
EquatableArray InstanceMethods,
EquatableArray ForwardedConstructors
-);
+)
+{
+ public const string ConstraintValidationMethodSymbol = "IsValid";
+};
+
+internal readonly record struct ExtractedOptions(int Options, int ConstraintOptions, int MethodImpl);
internal readonly record struct BinaryOperatorInfo(
string Name,
diff --git a/NewType.Generator/AliasModelExtractor.cs b/NewType.Generator/AliasModelExtractor.cs
index da34af1..9660e07 100644
--- a/NewType.Generator/AliasModelExtractor.cs
+++ b/NewType.Generator/AliasModelExtractor.cs
@@ -16,16 +16,17 @@ internal static class AliasModelExtractor
private const int OptionsNoImplicitWrap = 1;
private const int OptionsNoImplicitUnwrap = 2;
private const int OptionsNoConstructorForwarding = 4;
+ private const int OptionsUseConstraints = 1;
+ private const int OptionsConstraintsInRelease = 2;
public static AliasModel? Extract(
GeneratorAttributeSyntaxContext context,
ITypeSymbol aliasedType,
- int options,
- int methodImpl)
+ ExtractedOptions allOptions)
{
var typeDecl = (TypeDeclarationSyntax)context.TargetNode;
var typeSymbol = (INamedTypeSymbol)context.TargetSymbol;
-
+
var typeName = typeSymbol.Name;
var ns = typeSymbol.ContainingNamespace;
var namespaceName = ns is {IsGlobalNamespace: false} ? ns.ToDisplayString() : "";
@@ -35,7 +36,6 @@ internal static class AliasModelExtractor
|| (typeDecl is RecordDeclarationSyntax rds
&& !rds.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword));
var isRecord = typeDecl is RecordDeclarationSyntax;
- var isRecordStruct = isRecord && !isClass;
var aliasedTypeFullName = aliasedType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var aliasedTypeMinimalName = aliasedType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
@@ -55,7 +55,11 @@ internal static class AliasModelExtractor
var constructors = ExtractForwardableConstructors(typeSymbol, aliasedType);
var typeDisplayString = typeSymbol.ToDisplayString();
+ var validIsValid = HasValidIsValid(typeSymbol, aliasedType);
+ var useConstraints = (allOptions.ConstraintOptions & OptionsUseConstraints) != 0;
+
+
return new AliasModel(
TypeName: typeName,
Namespace: namespaceName,
@@ -63,7 +67,7 @@ internal static class AliasModelExtractor
IsReadonly: isReadonly,
IsClass: isClass,
IsRecord: isRecord,
- IsRecordStruct: isRecordStruct,
+ Location: typeSymbol.Locations.FirstOrDefault(),
AliasedTypeFullName: aliasedTypeFullName,
AliasedTypeMinimalName: aliasedTypeMinimalName,
AliasedTypeSpecialType: aliasedType.SpecialType,
@@ -73,10 +77,13 @@ internal static class AliasModelExtractor
HasNativeEqualityOperator: hasNativeEquality,
TypeDisplayString: typeDisplayString,
HasStaticMemberCandidates: hasStaticMemberCandidates,
- SuppressImplicitWrap: (options & OptionsNoImplicitWrap) != 0,
- SuppressImplicitUnwrap: (options & OptionsNoImplicitUnwrap) != 0,
- SuppressConstructorForwarding: (options & OptionsNoConstructorForwarding) != 0,
- MethodImplValue: methodImpl,
+ SuppressImplicitWrap: (allOptions.Options & OptionsNoImplicitWrap) != 0,
+ SuppressImplicitUnwrap: (allOptions.Options & OptionsNoImplicitUnwrap) != 0,
+ SuppressConstructorForwarding: (allOptions.Options & OptionsNoConstructorForwarding) != 0,
+ MethodImplValue: allOptions.MethodImpl,
+ IncludeConstraints: useConstraints,
+ DebugOnlyConstraints: (allOptions.ConstraintOptions & OptionsConstraintsInRelease) == 0, // inverse
+ validValidationMethod: validIsValid,
BinaryOperators: binaryOperators,
UnaryOperators: unaryOperators,
StaticMembers: staticMembers,
@@ -355,6 +362,24 @@ private static string GetConstructorSignature(IMethodSymbol ctor)
}));
}
+ private static bool HasValidIsValid(ITypeSymbol targetType, ITypeSymbol aliasedType)
+ {
+ // does not have to be static, even it is more "hygienic"
+ // seems like it would a be a little annoying to enforce if not strictly needed
+ IMethodSymbol? isValidMethod =
+ targetType
+ .GetMembers(AliasModel.ConstraintValidationMethodSymbol)
+ .OfType()
+ .FirstOrDefault(m =>
+ m.ReturnType.SpecialType == SpecialType.System_Boolean &&
+ m.Parameters.Length == 1 &&
+ SymbolEqualityComparer.Default.Equals(
+ m.Parameters[0].Type,
+ aliasedType));
+
+ return isValidMethod != null;
+ }
+
private static string FormatDefaultValue(IParameterSymbol param)
{
var value = param.ExplicitDefaultValue;
diff --git a/NewType.Generator/AnalyzerReleases.Shipped.md b/NewType.Generator/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000..60b59dd
--- /dev/null
+++ b/NewType.Generator/AnalyzerReleases.Shipped.md
@@ -0,0 +1,3 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
diff --git a/NewType.Generator/AnalyzerReleases.Unshipped.md b/NewType.Generator/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000..cf30ff2
--- /dev/null
+++ b/NewType.Generator/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,8 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+| Rule ID | Category | Severity | Notes |
+|------------|-----------|----------|----------------|
+| NEWTYPE001 | `Unknown` | Error | AliasGenerator |
\ No newline at end of file
diff --git a/NewType.Generator/NewType.Generator.csproj b/NewType.Generator/NewType.Generator.csproj
index 60e8b63..4de361b 100644
--- a/NewType.Generator/NewType.Generator.csproj
+++ b/NewType.Generator/NewType.Generator.csproj
@@ -49,5 +49,9 @@
+
+
+
+
diff --git a/NewType.Tests/ConstraintValidationTests.cs b/NewType.Tests/ConstraintValidationTests.cs
new file mode 100644
index 0000000..e1296b0
--- /dev/null
+++ b/NewType.Tests/ConstraintValidationTests.cs
@@ -0,0 +1,62 @@
+using System.Numerics;
+using newtype.tests;
+using Xunit;
+
+public class ConstraintValidationTests
+{
+ [Fact]
+ public void Direction_Valid()
+ {
+ //doesn't throw
+ var dir = new Direction(new Vector2(0, 1));
+ }
+
+ [Fact]
+ public void Direction_ValidImplicit()
+ {
+ //doesn't throw
+ Direction dir = new Vector2(0, 1);
+ }
+
+ [Fact]
+ public void Direction_Valid_Forwarded()
+ {
+ //doesn't throw
+ var dir = new Direction(0, 1);
+ }
+
+ [Fact]
+ public void Direction_CreateInvalid_Throws()
+ {
+ Assert.Throws(() => new Direction(new Vector2(999, 999)));
+ }
+
+ [Fact]
+ public void Direction_CreateInvalidImplicit_Throws()
+ {
+ Assert.Throws(() => (Direction)new Vector2(999, 999));
+ }
+
+ [Fact]
+ public void Direction_CreateInvalidForwarded_Throws()
+ {
+ Assert.Throws(() => new Direction(999, 999));
+ }
+
+ [Fact]
+ public void Direction_ForwardedOperation_ValidResult()
+ {
+ var dir1 = new Direction(new Vector2(0, 1f));
+ var dir2 = new Direction(new Vector2(0, 1f));
+ var dir3 = dir1 * dir2;
+ }
+
+ [Fact]
+ public void Direction_ForwardedOperation_InvalidResult_Throws()
+ {
+ var dir1 = new Direction(new Vector2(0, 1f));
+ var dir2 = new Direction(new Vector2(0, 1f));
+
+ Assert.Throws(() => dir1 + dir2);
+ }
+}
\ No newline at end of file
diff --git a/NewType.Tests/Types.cs b/NewType.Tests/Types.cs
index 7f3673a..2049999 100644
--- a/NewType.Tests/Types.cs
+++ b/NewType.Tests/Types.cs
@@ -8,6 +8,17 @@ namespace newtype.tests;
[newtype]
public readonly partial struct Velocity;
+[newtype(ConstraintOptions = NewtypeConstraintOptions.ReleaseEnabled)]
+public readonly partial struct Direction
+{
+ private static bool IsValid(Vector2 direction)
+ {
+ // enforce normalized
+ const float epsilon = 0.0001f;
+ return Math.Abs(direction.LengthSquared() - 1f) < epsilon;
+ }
+}
+
[newtype]
public readonly partial struct Scale;
@@ -103,6 +114,5 @@ public partial class DisplayName;
// "Extending Your Types" example — custom cross-type operator
public readonly partial struct Position
{
- public static Position operator +(Position p, Velocity v)
- => new(p.Value + v.Value);
+ public static Position operator +(Position p, Velocity v) => new(p.Value + v.Value);
}
\ No newline at end of file