Patterns and regex
[Pattern] has two forms, and on an AOT-published binary the choice between them is worth about 450 KB.
// Inline
[Pattern("^[A-Z]{3}$")]
public string? Sku { get; init; }
// Referenced
[Pattern(typeof(PetPatterns), nameof(PetPatterns.Sku))]
public string? Sku { get; init; }Both are correct. Both publish AOT-clean — neither uses RegexOptions.Compiled, so nothing goes through Reflection.Emit. What differs is size.
Why the inline form costs so much
The inline form emits a Regex built from a string:
private static readonly Regex SkuPattern0 = new Regex("^[A-Z]{3}$");Constructing a Regex from a pattern string at run time means the regex parser and interpreter have to be in the binary, because the pattern is not known until the constructor runs. The trimmer cannot remove them. That is roughly 450 KB on a published AOT binary — paid once, however many patterns follow.
The referenced form points at a member you declared:
public static partial class PetPatterns {
[GeneratedRegex("^[A-Z]{3}$")]
public static partial Regex Sku();
}[Pattern(typeof(PetPatterns), nameof(PetPatterns.Sku))]
public string? Sku { get; init; }if (value.Sku is not null && !global::MyApp.PetPatterns.Sku().IsMatch(value.Sku))
ctx.AddPattern("sku");[GeneratedRegex] is the .NET regex source generator: it emits a matcher specialised to that one pattern, as straight-line C#. No parser, no interpreter, and the matching itself is usually faster too. About 16 KB for the same pattern.
Why this library cannot emit [GeneratedRegex] for you
It could — but a source generator cannot see another generator's output, so the regex generator would never see the partial method this one emitted, and the partial would have no implementation. Declaring the member in your own source is what puts it where the regex generator can find it.
The policy
Which form is acceptable is governed by ValidationModules_PatternPolicy:
| Value | Inline patterns |
|---|---|
Allow | accepted silently |
Warn | VM0017 as a warning, and still emitted |
Error | VM0017 as an error, and the constraint is dropped |
| (unset) | Error if the project is AOT-facing, Allow otherwise |
<PropertyGroup>
<ValidationModules_PatternPolicy>Warn</ValidationModules_PatternPolicy>
</PropertyGroup>"AOT-facing" means PublishAot or IsAotCompatible is true. Both, rather than PublishAot alone, and the distinction matters: PublishAot is only ever true in the executable, so a class library holding your models would never see it. IsAotCompatible is what a library sets when it means to be publishable, and catching that is the difference between the diagnostic landing on the library's own build and landing on somebody else's publish.
Set Error explicitly in a library that ships to AOT consumers, so the failure is yours.
When the constraint is dropped
Under Error, the offending constraint is dropped and the rest of the type is still emitted:
public record Pet {
[Required]
public string? Name { get; init; }
[Pattern("^[A-Z]{3}$")]
public string? Sku { get; init; }
}The build fails with VM0017, and the emitted file contains the required check and no regex. That is deliberate: the build should fail with one useful diagnostic, not also with a second, less useful error out of a generated file.
Referencing a member
The member can be a method, a property or a field, and must be:
- static — there is no instance for the validator to reach,
- parameterless, if it is a method,
- of type
Regex, - at least
internal, so the generated validator in the same assembly can see it.
Anything else is VM0018, which names the reason:
VM0018: 'MyApp.PetPatterns.Sku' is not static, so the pattern on 'Sku' cannot be emittedA field works if you would rather not write a method:
public static partial class PetPatterns {
[GeneratedRegex("^[A-Z]{3}$")]
public static partial Regex Sku();
// or, without the source generator, at the cost of the parser being rooted:
public static readonly Regex Legacy = new("^[a-z]+$");
}Matching semantics
Patterns are unanchored, following JSON Schema. [Pattern("abc")] matches "xabcx". Write ^…$ if you mean the whole value.
This is the one place the native attribute and the DataAnnotations front end deliberately differ: [RegularExpression] from DataAnnotations is anchored — it checks that the match starts at 0 and consumes the whole value — and the front end reproduces that faithfully rather than quietly changing the meaning of a model you moved across.
A null value is not tested. Combine with [Required] if absence should also fail.
RegexOptions
[Pattern("^[a-z]{3}$", Options = RegexOptions.IgnoreCase)]Honoured, with one exception: RegexOptions.Compiled is VM0016. It emits IL through Reflection.Emit, which is the exact habit this library exists to remove — and it does nothing here anyway, because patterns go through [GeneratedRegex] or a plain constructor.
For the referenced form, options belong on your [GeneratedRegex] declaration instead; the attribute's Options is not consulted when a member is named.
Invalid patterns
Caught at build time, with the regex engine's own message:
[Pattern("[")] // VM0006: The pattern on 'Sku' is not a valid regular expression: …Re-describing the parser's complaint would produce a worse message than the one it already gives.