Partial types and members in C#: compile-time merging rules, generated code, partial methods, partial properties and C# 14 partial constructors and events.
A partial type is one class, struct, record or interface split across several declarations (usually files) that the compiler merges into a single type at compile time. All parts need partial, must be in the same assembly and namespace, and their modifiers, base types and interfaces are combined. Their main purpose is separating generated code from hand-written code: designers and source generators such as [GeneratedRegex], [LoggerMessage] and JsonSerializerContext write the other half. Partial members let one part declare a member and another implement it: partial methods (classic ones may be left unimplemented and removed), partial properties and indexers (C# 13), and partial constructors and events (C# 14).
Partial types let one class, struct, interface or record be declared across multiple files; the compiler merges them into a single type. Partial members let one part declare a member and another part implement it. They exist mainly so generated code and hand-written code can live side by side without editing each other — which is why source generators depend on them.
// Order.cs
public sealed partial class Order
{
public OrderId Id { get; }
public void Submit() { Validate(); /* ... */ }
}
// Order.Validation.cs
public sealed partial class Order
{
private void Validate() { /* ... */ }
}
partial, be in the same assembly and namespace, and have the same kind (class/struct/record/interface).sealed, abstract or declares a base class, it applies to the whole type; interface lists are unioned; conflicting base classes are an error.A Roslyn source generator can only add new source files to a compilation; it can't modify yours. Partial types let it add members to your type:
public static partial class Log
{
[LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} submitted")]
public static partial void OrderSubmitted(ILogger logger, Guid orderId); // generator implements this
}
[JsonSerializable(typeof(OrderDto))]
internal partial class AppJsonContext : JsonSerializerContext { } // generator adds metadata members
public static partial class Patterns
{
[GeneratedRegex(@"^\d{11}$")]
public static partial Regex Abn(); // generator emits the regex code
}
This enables compile-time code generation for logging, JSON serialisation (needed for NativeAOT/trimming), regex, P/Invoke ([LibraryImport]), configuration binding, MVVM toolkits, Mapperly mappers and more. Older uses: WinForms/WPF designer files (Form1.Designer.cs) and EF Core scaffolding.
Classic partial methods (C# 3):
partial void OnNameChanged(); // declaration, no body
private, must return void, no out parameters.Extended partial methods (C# 9):
public static partial Regex Abn(); // must be implemented by another part
out parameters.public partial class SettingsViewModel
{
[ObservableProperty]
public partial string DisplayName { get; set; } // declaration; generator implements with change notification
}
One part declares the property (signature only); another provides accessor bodies. This lets generators (e.g. MVVM toolkits) implement property logic without requiring fields-with-attributes tricks. [GeneratedRegex] also works on partial properties.
C# 14 extends the same model to instance constructors and events: a declaring part provides the signature and a single implementing part provides the body (for events, the add/remove accessors). This lets generators implement constructor logic (e.g. dependency wiring, interop initialisation) or weak-event patterns while the user declares the shape.
We migrated an API to NativeAOT for faster start-up in Azure Container Apps. Reflection-based System.Text.Json serialisation and some Regex usages produced trimming warnings. Adding a partial JsonSerializerContext with [JsonSerializable] attributes and converting hot regexes to [GeneratedRegex] partial methods removed the warnings, cut cold start noticeably, and made serialisation faster even in the JIT build. Partial members were the mechanism that let generated code slot into our types.
Checks that the candidate understands the compile-time nature of partial types and the modern generator use cases.
Green flags: merging rules; same assembly; generators like [GeneratedRegex], [LoggerMessage], JSON contexts; classic versus extended partial methods; partial properties (C# 13) and constructors/events (C# 14); "not a refactoring tool".
Red flags: thinks partial exists at run time or across assemblies; uses partial to split god classes; unaware of source generators.
partial must be the last modifier before class/struct/record/interface (or before the return type for members).Incremental generators (IIncrementalGenerator) inspect the compilation via syntax and semantic models (e.g. ForAttributeWithMetadataName("System.Text.RegularExpressions.GeneratedRegexAttribute", ...)), then call context.AddSource("Patterns.g.cs", source) to emit a file like:
partial class Patterns
{
public static partial Regex Abn() => AbnRegex.Instance; // implementing part
// ... generated regex runner ...
}
The generated file is part of the compilation (visible in the IDE under Dependencies → Analyzers, or emitted to disk with <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>). Because the generator can only add code, your declaration must be partial and accessible in the right way.
partial class Customer
{
partial void OnNameChanging(string value);
public string Name { get => _name; set { OnNameChanging(value); _name = value; } }
}
If no implementation exists, the call OnNameChanging(value) is removed including argument evaluation. So OnNameChanging(ExpensiveCompute()) doesn't even call ExpensiveCompute() when unimplemented — a subtle semantic difference worth knowing.
| Rule | Classic partial method | Extended partial method/property/constructor/event |
|---|---|---|
| Implementation required | No | Yes, exactly one |
| Accessibility | Implicitly private | Explicit (public, etc.) |
| Return type | void only |
Any |
out parameters |
No | Yes |
| Can be virtual/override/sealed | No | Yes |
Signatures, including nullability and scoped/params modifiers, must match between declaration and implementation (with some flexibility for parameter names and attributes).
| Feature | Partial declaration |
|---|---|
[LoggerMessage] |
partial logging methods |
[GeneratedRegex] |
partial method or property returning Regex |
[JsonSerializable] |
partial JsonSerializerContext |
[LibraryImport] |
partial P/Invoke methods |
| Configuration binding generator | intercepts calls (interceptors), less about partials |
[ObservableProperty] (CommunityToolkit.Mvvm) |
partial properties (C# 13) |
| Mapperly | partial mapper methods |
Some teams split a type into Order.cs, Order.Validation.cs, Order.Events.cs. It can help navigation for large aggregates, but it also hides size and coupling. A better default: extract cohesive concepts into their own types (value objects, policies, domain services). Use partial splits by hand mainly for generated code and platform conventions.
[GeneratedRegex] partial method and set EmitCompilerGeneratedFiles to inspect the output.JsonSerializerContext and compare serialisation performance and trimming warnings.// Source-generated regex (compile-time, AOT-friendly)
public static partial class Validators
{
[GeneratedRegex(@"^[A-Z]{3}$", RegexOptions.CultureInvariant)]
private static partial Regex CurrencyCodeRegex();
public static bool IsCurrencyCode(string s) => CurrencyCodeRegex().IsMatch(s);
}
// Source-generated, allocation-free logging
public static partial class Log
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Information,
Message = "Order {OrderId} placed for {Total}")]
public static partial void OrderPlaced(ILogger logger, int orderId, decimal total);
}
// System.Text.Json source generation for Native AOT
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
internal partial class AppJsonContext : JsonSerializerContext;
// Classic optional hook: removed if never implemented
public partial class Invoice
{
public void Save() { OnSaving(); /* persist */ }
partial void OnSaving(); // no implementation -> call vanishes
}
// C# 13 partial property and C# 14 partial constructor (one part each)
public partial class Customer
{
public partial string Name { get; set; } // declaring part
public partial Customer(string name); // declaring part (C# 14)
}
public partial class Customer // implementing part (often generated)
{
private string _name = "";
public partial string Name { get => _name; set => _name = value.Trim(); }
public partial Customer(string name) => Name = name;
}
What happens: a huge class is split across files to look manageable. Why it is wrong: responsibilities and coupling remain; the class is still too big. Do instead: extract cohesive types; use partial splits mainly for generated code.
partial on the containing typeWhat happens: a generator can't add members, or nested partial types fail.
Why it is wrong: generated code can't merge into a non-partial type.
Do instead: mark the type (and containing types) partial as the generator requires.
What happens: developers modify .g.cs or designer files.
Why it is wrong: changes are overwritten on the next build or regeneration.
Do instead: put customisations in your own partial part or generator configuration.
What happens: one part says public, another internal, or different base classes.
Why it is wrong: compile errors or surprising merged modifiers (sealed from one part applies to all).
Do instead: keep type-level modifiers in one primary part.
What happens: OnChanged(Compute()) where Compute has side effects.
Why it is wrong: if unimplemented, the whole call including Compute() is removed.
Do instead: avoid side effects in partial method arguments.
What happens: trying to extend a type from another project with partial.
Why it is wrong: all parts must be in the same assembly.
Do instead: use extension methods/members, composition or inheritance.
What happens: reflection-based serialisation and regexes kept in trimmed/AOT apps.
Why it is wrong: trimming warnings, runtime failures and slower start-up.
Do instead: use partial-based generators (JsonSerializerContext, GeneratedRegex, LoggerMessage).
What happens: parts scattered in different folders.
Why it is wrong: developers can't find the full type definition.
Do instead: keep parts together with clear naming (Order.Validation.cs).
Seniors see partial as plumbing for code generation, not as a way to organise their own code.
| Need | Use partial? |
|---|---|
| Source generator must add members | Yes, it is required |
| Designer-generated UI code | Yes |
| Organise a large class | No, refactor |
| Optional hook in generated code | Classic partial method |
| Generator-implemented property or constructor | C# 13 partial property / C# 14 partial constructor |
They connect partial to Native AOT and trimming: generators replace run-time reflection (regex, JSON, logging, P/Invoke marshalling) with compile-time code, which improves start-up and makes AOT possible.
A static class cannot be instantiated or inherited and contains only static members; in IL it is abstract sealed. It is the right home for stateless helpers (Math, Path), constants and extension methods, which must be declared in a non-generic, non-nested static class. C# 14 adds extension blocks (extension(Order o) { ... }) inside static classes, so you can add extension properties and static extension members too. Static classes cannot implement interfaces or be injected, so static state is global, shared across threads and hard to replace in tests. Prefer a DI-registered singleton when something has state, configuration or I/O.
A constructor runs when an object is created and should leave it valid. If you declare none, the compiler adds a public parameterless one. Constructors can chain: : this(...) calls another constructor of the same class, : base(...) calls the base class's. For new Derived(), the order is: derived field initialisers, base field initialisers, base constructor body, derived constructor body. A static constructor runs once, before the type is first used, and is thread-safe. Primary constructors (C# 12) put parameters on the class declaration; they are captured as hidden fields, not properties, and are mutable. required members (C# 11) force callers to set them in an object initialiser. Calling a virtual method from a constructor runs the derived override before the derived constructor body has run, so it can see uninitialised state.
How to tell it (STAR)
**Situation:** our ingestion function had slow cold starts, and profiling showed regex construction as a big part. **Task:** cut cold start without changing matching behaviour. **Action:** I moved all [40] patterns to `[GeneratedRegex]` partial methods, kept the same options, ran the existing tests and enabled SYSLIB1045 so new regexes follow the pattern. **Result:** cold start dropped by about [600 ms], and the function could later be published with Native AOT.
Last updated 30 Oct 2026.