Introduce Microsoft.AspNetCore.FormData with source-generated form binding

Open
#70,365 2 comments 1 reaction 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
25/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Active
Tech stack
csharp

Research direction

Start with the parent epic, https://github.com/dotnet/aspnetcore/issues/70364, and read this proposal’s sections on public contracts, Minimal API and Blazor integration, and generation. The issue describes a framework-wide API, source generator, and host integrations but names no implementation files or tests to start from. Done means delivering the proposed form-data contracts and generated binding with the stated AOT, compatibility, and error-reporting behavior.

Written by the indexing model from the issue text.

Description

api-proposal area-blazor area-minimal feature-model-binding feature-source-generators feature-trimming NativeAOT

Parent epic: https://github.com/dotnet/aspnetcore/issues/70364

Summary

This document proposes adding Microsoft.AspNetCore.FormData to ASP.NET Core: a supported form-data reading API, a form-specific metadata and attribute model, and source generation for reading form fields and uploaded files into .NET models. It addresses the lack of supported customization contracts and generated complex-model binding in the implementation currently shared by Blazor static server rendering and Minimal APIs. Applications can describe names, exclusion, construction, culture, empty values, and custom conversions without depending on internal framework types or adapting a JSON converter.

The public experience follows System.Text.Json (STJ) idioms: typed contracts, serializer contexts, options, resolvers, attributes, and composable generated definitions. The semantics are specific to form data, which consists of named string values and files rather than JSON tokens. A standalone generator supports explicit C# contexts; Razor owns Blazor form-model discovery across .razor and .cs inputs and supplies the same contracts without a competing discovery pipeline.

STJ supplies metadata/provider idioms, not feature parity. The feature covers existing form-mapping capabilities and the form-specific additions described here, without automatic naming transformations or unrelated STJ-style options.

Goals

  1. Make complex form models usable with Native AOT and full trimming. Supported Blazor and Minimal API forms must run with reflection-based form metadata discovery and runtime code generation disabled. The same submitted fields, files, contract, and read settings must produce equivalent values and mapping errors through generated and compatibility metadata.

  2. Make form contracts explicit and customizable. Developers must be able to rename, exclude, require, construct, and convert supported model properties using form-owned attributes and metadata APIs. Form models must support ordinary objects, records, value types, supported collections and dictionaries, recursive models, and uploaded files without importing MVC's complete binder model or duplicating STJ's optional features.

  3. Make input interpretation deliberate. Parsing uses invariant culture by default with a supported property-level current-culture opt-in, not a global setting. Missing input, empty input, repeated values, numeric collection indexing, and bracketed dictionary keys have documented semantics; custom converters handle alternative representations. In particular, a nullable custom value object must be able to interpret an empty field as null without a string-proxy property.

  4. Make failures actionable without discarding useful data. Invalid contracts must identify the offending type/member before request handling when statically knowable. Invalid submitted data must expose structured field errors and retain successfully mapped data where construction is possible, enabling Blazor redisplay and explicit Minimal API error handling.

  5. Support framework and library composition without forcing migration. Application and library definitions must compose through the same contracts. Existing applications retain a supported compatibility path; adopting the new API must not require changing HTTP form encodings, Blazor form routing, or the application's validation framework.

Non-goals

  1. Writing objects into form fields or HTTP bodies. This feature reads form data. It does not define serialization order, output omission, file upload production, or round-trip equivalence.

Proposed solution

Microsoft.AspNetCore.FormData supplies public runtime contracts independent of the Components implementation namespace. Consumers can use it directly or through Blazor and Minimal API integration. The HTTP-facing input is an already-read IFormCollection, including its files; calling the serializer does not read or rewind a request body.

The public surface includes:

Surface Consumer responsibility and observable behavior
FormDataSerializer Reads using a supplied typed contract, or options with a configured resolver. Provides a throwing convenience API and an error-collecting API.
FormDataTypeInfo<T> / FormDataTypeInfo Describes the effective form shape and executable mapping capability for a type. Child types, members, constructor parameters, collection elements, and dictionary keys/values are represented without requiring consumers to obtain reflected members.
FormDataPropertyInfo / FormDataParameterInfo Exposes effective field names, exclusion, presence requirements, conversion policy, and construction/assignment associations. Occurrence-level settings can differ for two properties of the same CLR type.
FormDataSerializerContext Supplies generated definitions for explicitly declared roots and their required reachable types.
IFormDataTypeInfoResolver / FormDataTypeInfoResolver Resolves type contracts and composes application, library, and optional reflection providers in a documented order.
FormDataSerializerOptions Configures contract resolution and converters. A contract is associated with its options; mutable options are not shared across unrelated applications. It supplies no naming transformations or global value/culture policies.
FormDataReadOptions Supplies root prefix, mapping limits, and diagnostic retention without changing cached contracts. Culture selection is not an operation setting.
FormDataResult<T> / FormDataError Reports Success, NoData, or InvalidData, whether a value was produced, a possibly partial value, and structured field errors.
FormDataConverter<T> Allows a custom scalar or structured form representation using public form-reader and error-reporting contracts. It is not a JSON converter or an MVC binder.

Contract customization happens before use. Contracts and their associated serializer options become read-only when used or explicitly frozen. A resolver modifier can customize generated definitions as well as reflection-provided definitions; it cannot mutate an already-used contract.

The attributes describe the capabilities and precedence rules below.

Annotation Purpose and scope
FormDataSerializable(typeof(T)) Declares an explicit generated context root.
FormDataSourceGenerationOptions Declares generation-time defaults and strict generated-only expectations for a context. It supplies no global culture choice and does not bake a request's current culture into generated metadata.
FormDataPropertyName("field-name") Selects one literal form-name segment for a supported property or constructor parameter instead of its CLR name.
FormDataIgnore Excludes a property from reading, like the existing legacy exclusion annotation. No conditional-ignore modes are introduced.
FormDataRequired Requires submitted input for a member or constructor parameter. This is presence, not DataAnnotations validation.
FormDataConstructor Selects one construction entry point when public constructor selection is ambiguous.
FormDataUseCurrentCulture Opts a scalar property or that property's scalar collection elements into current-culture parsing. Unannotated properties remain invariant. The setting belongs to property metadata, including when the property is associated with a constructor parameter.
FormDataEmptyStringHandling(...) Selects Preserve, AsNull, or Reject for a scalar member/parameter or that member's scalar collection elements. Whitespace-only text is not automatically empty.
FormDataConverter(typeof(...)) Selects a custom converter at type or member/parameter scope.

Attributes describe the contract. Resolver APIs must allow equivalent customization for types an application cannot annotate. No attribute is required merely to preserve a public settable property's CLR name.

Examples below are independent consumer-code fragments, not one combined program. Contexts such as BookingFormContext and OrderFormContext follow the explicit root declaration shown in the first scenario; the referenced converter classes are application-supplied types.

Read a simple form with a generated context

An application declares a model and an explicit context, then reads an already-parsed form using strongly typed metadata.

public sealed class ContactInput
{
    public string? Name { get; set; }
    public int Age { get; set; }
}

[FormDataSerializable(typeof(ContactInput))]
internal partial class ContactFormContext : FormDataSerializerContext
{
}

ContactInput? contact = FormDataSerializer.Deserialize(
    form, ContactFormContext.Default.ContactInput);

Name=Ana&Age=42 produces the corresponding values. Names match ordinally without case sensitivity by default. Age=abc causes Deserialize to throw a FormDataException containing mapping errors; it does not return a successfully populated object containing an unexplained default age.

The generated context covers supported types reachable from ContactInput. No runtime model discovery is needed to read the form.

Adopt the contracts in Minimal APIs and Blazor

Applications can register shared form-data options and generated contexts while keeping their existing host entry points.

builder.Services.AddFormData(options =>
    options.TypeInfoResolverChain.Add(ContactFormContext.Default));

app.MapPost("/contacts", ContactHandlers.Create);

ContactHandlers.Create has a [FromForm] ContactInput parameter. With the new integration enabled, both the runtime endpoint path and the Request Delegate Generator use the registered contract. Supported complex input no longer causes the generator to skip the endpoint because form mapping requires dynamic code.

[GenerateFormDataMetadata]
public partial class ContactComponent : ComponentBase
{
    [SupplyParameterFromForm]
    private ContactInput Input { get; set; } = new();
}

Razor-owned Blazor generation discovers receiver types from component declarations and their base classes across Razor and C# inputs. The example uses a C# component declaration; equivalent Razor declarations receive the same form-contract support. Generated definitions are composed through the resolver chain; the developer does not maintain a second root list. Private nested models remain private when legally accessible from their generated context. Unsupported declarations and collisions produce compile-time diagnostics. Components that do not opt into the new path retain their existing behavior. Explicit library contexts can supply definitions for types they own; discovery does not silently replace such definitions.

Components use ordinary or enhanced named EditForm instances with the same supported form reading and mapping-error behavior. Explicit C# contexts and Blazor-discovered form roots target the same contracts.

Existing [FromForm(Name = ...)] field selectors, [SupplyParameterFromForm(Name = ..., FormName = ...)], and form scopes retain their supported host roles. These selectors are not renamed to FormData attributes. In the current Minimal API complex-model path, FromForm.Name does not select a nested model prefix: complex properties are read from the empty model root. Preserve that behavior rather than introduce a new prefixed complex-binding feature. Direct core reads can select an explicit root prefix; host adaptation must not assume every host interprets its name selector as that prefix. The core reader does not route a form to a component.

Registration and activation must be explicit for existing applications. This proposal does not silently switch an existing application's mapper semantics merely because an updated framework contains the new assembly. Generated model availability and selection of the new runtime path are separate concerns.

Rename, ignore, and require members

Today a shared-mapper model can use DataMember and IgnoreDataMember, but has no form-owned equivalents or public contract customization. The new annotations provide a form-specific vocabulary.

public sealed class ContactInput
{
    [FormDataPropertyName("first_name")]
    [FormDataRequired]
    public string? FirstName { get; set; }

    [FormDataIgnore]
    public bool IsAdministrator { get; set; }

    public string? CampaignCode { get; set; }
}

first_name=Ana&IsAdministrator=true&CampaignCode=autumn sets FirstName and CampaignCode, but never assigns IsAdministrator. Missing first_name is a presence error; first_name= is present and must be considered separately by empty-value policy and validation. Public instance properties with public setters and constructor-associated properties retain the supported mapper model. Fields, non-public model accessors, static members, and indexers are not newly included.

Names are unchanged CLR names unless explicitly overridden by FormDataPropertyName or applicable DataMember.Name. There is no global naming policy, automatic casing transformation, or custom naming-policy hook. Two mapped properties whose effective names collide under the existing case-insensitive name matching make the contract invalid; the generator identifies both properties. Constructor/property associations use CLR identity, not renamed form-field text.

Names containing spaces, hyphens, underscores, and non-Latin characters are supported. In the dotted/bracketed grammar, ., [ and ] are structural characters and cannot be literal member-name segments; such an attribute is diagnosed rather than reinterpreted as a path. A custom converter is required for a different path grammar or a dictionary-key representation that includes those delimiters.

Renaming the read contract does not automatically rewrite existing rendered input names. The form must submit the effective name, for example <input name="first_name">. Changing Blazor's existing input-name generation is not silently included in this feature; metadata exposes effective names for hosts and components that deliberately integrate them.

Preserve legacy annotations without interpreting JSON or MVC member annotations

Before adoption, an existing shared-mapper model can express these settings with legacy annotations:

public sealed class LegacyInput
{
    [DataMember(Name = "first_name", IsRequired = true)]
    public string? FirstName { get; set; }

    [IgnoreDataMember]
    public string? ServerOnly { get; set; }
}

After adoption, that same model can retain those annotations or use the form-owned equivalents:

public sealed class LegacyInput
{
    [FormDataPropertyName("first_name")]
    [FormDataRequired]
    public string? FirstName { get; set; }

    [FormDataIgnore]
    public string? ServerOnly { get; set; }
}

The new contract recognizes DataMember.Name, DataMember.IsRequired, and IgnoreDataMember. DataMember.IsRequired applies even when no name is supplied. C# required-member metadata also expresses an input-presence requirement unless the selected constructor is declared to satisfy required members. DataContract does not turn the model into a DataContract serializer, and DataMember.Order and EmitDefaultValue have no reading semantics.

Precedence is: explicit resolver customization, form-owned annotation for the same setting, applicable legacy annotation, convention. Exclusion is evaluated before resolving an excluded property's type. Either FormDataIgnore or IgnoreDataMember excludes the property; no inclusion or conditional-ignore annotation overrides it. Explicit metadata customization can describe the effective supported-property contract without annotations. Conflicting explicit form annotations that cannot be reconciled are contract errors.

JsonPropertyName, JsonIgnore, JsonConstructor, JsonRequired, JsonConverter, and MVC member-level FromForm, BindNever, and BindRequired do not customize the new contract. Generation reports a targeted warning when such a binding-looking annotation would otherwise be the only indication of the developer's intended form customization. It does not warn on unrelated validation/display attributes. Root-level host selectors remain supported as described above.

Opt into current-culture parsing

Today the inspected shared complex-form paths pass invariant culture, and the conversion classes needed to change that are internal. The new API keeps invariant culture as the default and supports only one built-in alternative: current parsing culture, selected on an individual property. Serializer options, read options, and generation defaults do not select culture.

public sealed class BookingInput
{
    [FormDataUseCurrentCulture]
    public decimal Amount { get; set; }

    public DateOnly TravelDate { get; set; }
}

BookingInput? booking = FormDataSerializer.Deserialize(
    form, BookingFormContext.Default.BookingInput);

When the application's current culture is fr-FR, Amount=1,25&TravelDate=2026-12-31 produces 1.25m and December 31, 2026. Without FormDataUseCurrentCulture, a property remains invariant. Equivalent property-metadata customization supports types an application cannot annotate. The property's policy also applies when its value supplies an associated constructor parameter; it is not an independent parameter-level culture setting. Property settings apply to that scalar conversion or its scalar collection elements, not indiscriminately to an entire nested object graph. Root scalar values remain invariant unless an explicit converter supplies different behavior.

Current culture is resolved for each read, not when a contract is generated or first cached. CurrentUICulture controls message localization, not the interpretation of numbers and dates. The form-data API does not expose a named-culture string or an arbitrary CultureInfo setting.

HTML date, time, and number controls can submit machine-formatted invariant values even when their visible UI is localized. Applications must be able to keep those members invariant while parsing localized free-text members in the same form. Explicit formats not expressible by culture alone use a custom converter. Collection indexes and default bracketed dictionary keys remain invariant; changing a value's culture never changes the structural grammar.

No automatic culture fallback is attempted after a parse fails. Posted hidden fields cannot change the invariant/current choice or override the contract; MVC's __Invariant convention is not implicitly adopted. Applications needing another culture or an exact format use an explicit converter rather than expanding this built-in attribute.

Distinguish missing, empty, null, and invalid input

An optional domain value can choose null-on-empty explicitly.

public sealed class BookingInput
{
    [FormDataEmptyStringHandling(FormDataEmptyStringHandling.AsNull)]
    public BookingCode? Code { get; set; }

    [FormDataEmptyStringHandling(FormDataEmptyStringHandling.Reject)]
    public string? Reference { get; set; }
}

BookingCode denotes a custom struct with a supported parser or registered converter. Code= produces null without invoking a parser that can only return a non-nullable BookingCode. Reference= is an empty-input error. Code=bad is a conversion error, not null-on-failure.

Input state Required behavior
Member absent Do not assign a settable property; preserve its initializer. Report a presence error if required. Constructor defaults follow the construction rules below.
Present empty string Apply the member's empty-string policy before scalar conversion. An explicit AsNull is invalid for a non-nullable value target.
Present whitespace Pass through unchanged unless a converter explicitly trims it. Do not equate whitespace with absence.
Explicit null Ordinary form fields have no null token. The string "null" is text, not null. A converter can define another representation explicitly.
Invalid nonempty value Record a field conversion error. Do not claim successful conversion to default(T).

Defaults preserve strings, use null-on-empty for the built-in nullable scalar types supported by the legacy mapper, and delegate other scalar conversion to its parser. Custom nullable structs retain that default delegation for compatibility but can opt into AsNull, resolving the gap identified in #69901. Required-input presence does not add nullable-reference-annotation enforcement; DataAnnotations validation remains a later stage. Empty-value overrides belong to property/parameter metadata rather than a global serializer default.

Built-in scalar coverage includes strings, characters, Boolean values, signed/unsigned integer types including 128-bit types, floating-point types including Half, decimal, Guid, DateOnly, TimeOnly, DateTime, DateTimeOffset, TimeSpan, Uri, enums, and nullable forms of supported value types. Other IParsable<T> implementations are supported. Enum reading follows the current case-insensitive name/numeric parsing convention, including comma-separated flag names and representable numeric values; rejection of unnamed numeric values or custom enum spellings requires an explicit converter. Multidimensional arrays, open generic types, and unconfigured abstract/interface object models are not implicitly supported.

For a root with no matching submitted fields or files, the collecting API returns NoData, HasValue = false, and no mapping errors. The convenience API returns its default value for that absence. A host requiring a model must handle NoData explicitly; unrelated antiforgery and handler fields do not manufacture a model. An unchecked checkbox that produces no field is absence, not an instruction to assign false. Hosts and form controls retain responsibility for any hidden false-value convention.

For the new host integration, a required Minimal API form parameter with NoData is a bad request and the normal handler is not invoked; an optional parameter receives its declared default or null. A Blazor form receiver with NoData keeps an already initialized value rather than having it overwritten by a fabricated mapped default. If that receiver explicitly requires submitted input, absence is surfaced as a mapping requirement error. These host outcomes do not turn an omitted checkbox into an explicit false value.

Construct immutable models and resolve constructor ambiguity

Without explicit selection, the legacy single-public-constructor convention remains available. A model with multiple public constructors can opt into a selected construction entry point instead of silently binding null.

public sealed record ReservationInput(
    [property: FormDataPropertyName("guest_name")] string GuestName,
    int Seats = 1);

guest_name=Ana produces a reservation for one seat. The effective form name is shared by the constructor parameter and its associated property; the member is not mapped twice. Parameter-to-property matching uses CLR name and type, with case-insensitive name matching. An explicit parameter annotation overrides the associated property's setting; otherwise applicable property settings flow to the parameter.

[FormDataConstructor] on one supported constructor selects it when multiple constructors exist. No selected constructor and multiple public constructors is an actionable contract error. Required constructor parameters must be present; parameters with explicit defaults use those defaults when absent unless explicitly marked required. A missing required argument prevents invocation, yields InvalidData, and produces no instance of that containing object.

Constructor exceptions become mapping errors associated with the object's path, with safe client-facing text and the underlying exception available to application diagnostics. They are not mistaken for missing metadata. Construction follows the supported public-constructor model. No new private-constructor, field, or non-public model-accessor inclusion capability is introduced; explicit custom converters can implement application-specific construction.

Required C# construction assignments follow the same rule as required constructor arguments: when such a slot has no successfully mapped value, neither generated nor reflection execution constructs that containing instance. The result remains invalid with the corresponding presence/conversion error rather than inventing a default required assignment to make generated source compile. Successfully mapped nullable values still count as values. This does not turn optional properties into required slots or permit overwriting absent optional initializers.

Read-only computed properties are excluded unless associated with construction. init members are supported when the generated or explicitly supplied contract can initialize them; unsupported construction/access combinations identify the member. This feature constructs new models rather than updating an existing object graph.

Bind collections with predictable numeric indexes

Collections accept repeated scalar values (Tags=one&Tags=two) or consecutive numeric paths (Lines[0].Quantity=2&Lines[1].Quantity=3) without a collection-specific annotation.

public sealed class OrderInput
{
    public List<OrderLineInput>? Lines { get; set; }
}

Lines[0].Quantity=2&Lines[1].Quantity=3 produces two items in numeric-index order. Mixed repeated/indexed representations and invalid numeric indexes produce field errors. A gap followed by more submitted items is an error rather than silent truncation. These grammar rules apply to the new contract; legacy host behavior is not silently rewritten. Named .index protocols are not a built-in feature in this release; an application can implement one with a custom converter.

Scalar conversion failure in one element does not prevent diagnostics for later submitted elements. Mutable sequences, arrays, sets, queues, stacks, and immutable/read-only collection targets supported by the current mapper remain representable; sequence targets preserve logical element order, while sets and unordered collections retain their own semantics. Custom collection construction can be supplied through public contracts or converters. Recursive item types terminate based on actual input presence, not the mere availability of a child contract.

Required targets are one-dimensional arrays; concrete ICollection<T> types with public parameterless constructors; Queue<T>, Stack<T>, ReadOnlyCollection<T>; concurrent bag/queue/stack; immutable array/list/hash-set/sorted-set/queue/stack; and the standard generic enumerable, collection, list, set, read-only collection/list/set, and immutable list/set/queue/stack interfaces. Interface targets select the corresponding standard list, set, or immutable representation; they do not activate an arbitrary application implementation. A custom type outside this list needs an explicitly supplied contract or converter.

Bind dictionaries without truncating keys

The built-in dictionary format is Values[key]=value, with invariant key conversion and ordinal case-sensitive string-key identity. Form path matching is still case-insensitive; distinct entries that become ambiguous under that matching are rejected rather than arbitrarily combined.

This guarantee applies to identities still distinguishable in the supplied IFormCollection. An HTTP parser may already have combined case-equivalent field names into one StringValues entry; the mapper cannot recover original spelling or occurrences from that normalized input. Preserve that parser boundary and existing first-scalar-value behavior rather than introduce raw body parsing to infer lost key identities.

public sealed class LabelsInput
{
    public Dictionary<string, int>? Values { get; set; }
}

Values[category]=2 produces the string key category with value two. No dictionary-specific annotation is required.

Unmatched or ambiguous delimiters such as Values[x]y]=2 are a malformed-path error; the reader never substitutes key x and default value zero. Duplicate converted keys and invalid key conversion are errors. A custom key converter can extend scalar key conversion, but cannot change the built-in path grammar. Keys containing structural delimiters or a key/value-entry protocol require an explicit structured converter; no built-in dictionary-format selector is proposed.

Required dictionary targets are Dictionary<TKey, TValue>, sorted dictionary/list, concurrent dictionary, read-only dictionary, immutable dictionary/sorted dictionary, the standard dictionary/read-only dictionary/immutable dictionary interfaces, and concrete IDictionary<TKey, TValue> types with public parameterless constructors. Bracketed keys use ISpanParsable<TKey> by default; an explicit form key converter can extend that set. Key conversion remains invariant unless an explicit key converter supplies another interpretation.

Bind files consistently at roots and nested paths

The form-data contract distinguishes uploaded files from text values. Core support includes IFormFile, named IFormFile[], List<IFormFile> and IReadOnlyList<IFormFile>, and IFormFileCollection. Named sequences read multiple files submitted under the same field name; a custom converter can implement indexed file paths rather than a built-in collection-format selector.

public sealed class OrderLineInput
{
    public int Quantity { get; set; }
    public IReadOnlyList<IFormFile>? Attachments { get; set; }
}

Files posted as Lines[0].Attachments attach only to that member. No matching files do not manufacture another collection item or keep an indexed collection alive indefinitely. A root named file sequence respects the host-selected parameter name or explicit root name; an all-files target is not silently treated as a named sequence, or vice versa.

At a root, IFormFileCollection means all files supplied by the caller, independent of the model-name prefix. At a nested member it means files matching that member's full field name, with absence when none match. This explicit nested scoping differs from the current converter's unconditional all-files return and must be documented as an adoption change. A caller that needs an all-request file collection inside each child object supplies an explicit converter; that converter's availability does not establish that a child item exists.

File-only input counts as model data. Text and files in different nested members can be mapped together. A text field and a file with the same path are distinct input channels interpreted by the target contract. Blazor can supply its IBrowserFile integration without making the core assembly depend on Components. Reading a file reference does not copy, persist, or validate file contents.

Supply a custom converter without changing the model to strings

Applications can register a converter for a type or one occurrence.

var options = new FormDataSerializerOptions
{
    TypeInfoResolver = BookingFormContext.Default
};
options.Converters.Add(new BookingCodeFormConverter());

BookingInput? booking =
    FormDataSerializer.Deserialize<BookingInput>(form, options);
public sealed class BookingInput
{
    [FormDataConverter(typeof(IsoWeekDateFormConverter))]
    public DateOnly WeekDate { get; set; }
}

The referenced classes are application implementations of the proposed FormDataConverter<T> contract; converter implementations are intentionally not specified here. A converter can read a scalar's submitted values or a structured subtree, distinguish absent input from invalid input, use the effective culture, delegate a child to another resolved contract, and report errors at a child path. It must not bypass operation limits or access another root's scope. A custom scalar converter remains usable for repeated collection values and dictionary values.

Precedence is member/parameter converter, type annotation converter, ordered options converters, then built-in conversion. IParsable<T> is supported without an explicit converter. Arbitrary MVC TypeConverter discovery, BindAsync, MVC binders, and existing JSON converters are not automatically invoked; applications provide a form adapter explicitly where needed.

A converter for a base class can implement an application's allowlisted discriminator protocol. This does not imply built-in JsonDerivedType support. User code bugs and invalid converter contracts remain developer-visible exceptions; the reader does not convert arbitrary programming failures into successful default values.

Compose application and library definitions

A library can expose a generated context, and an application can combine it with its own.

var options = new FormDataSerializerOptions
{
    TypeInfoResolver = FormDataTypeInfoResolver.Combine(
        ApplicationFormContext.Default,
        LibraryFormContext.Default)
};

Resolvers are queried in order and the first non-null type definition wins. Contracts for the same CLR type are not merged. The options-based overload resolves child contracts through the effective composed options so nested customizations remain consistent. Direct typed-contract overloads use that contract's associated options and reject incompatible use instead of silently applying unrelated options.

Library definitions describe the types they own; composition does not require runtime assembly scanning. For a known form root, all required reachable type definitions and converters must be available. Recursive references do not require infinitely many generated definitions. An explicitly ignored unsupported member does not make its containing type unsupported.

Strict execution includes only generated or explicitly supplied definitions. Reflection compatibility is an explicitly selectable provider for applications that permit it, not an implicit fallback when a generated contract is missing. Build-time completeness checks apply to statically known roots; runtime-supplied resolver/converter configurations are checked when the contract is first resolved or registered, before a request handler receives its value.

For an application that intentionally permits reflection, new DefaultFormDataTypeInfoResolver() is the proposed explicit compatibility provider and can be added last in the resolver chain. Strict mode rejects registration of that provider. A generated-context-only chain is usable directly without registering a reflection provider or maintaining a second serialization model.

Collect errors and retain partial values

An application can read without throwing for expected input failures.

FormDataResult<OrderInput> result =
    FormDataSerializer.DeserializeWithErrors(
        form, OrderFormContext.Default.OrderInput);

For an OrderInput using default numeric indexes, Lines[0].Quantity=abc&Lines[1].Quantity=3 produces InvalidData; it includes a conversion error for the first quantity and retains the successfully read second quantity where the declared construction permits a partial model. Default values in failed slots are never evidence of success: Status and Errors must be checked. HasValue distinguishes a produced value, including a successful null, from no produced instance.

Each error supplies a stable category, submitted field path, target member identity where applicable, a safe message, and the attempted text value when the application requests its retention. Categories include missing required input, invalid value, disallowed empty/null, duplicate value/key, malformed path, and exceeded limit. File contents are never retained in an error. Attempted values are not automatically written to logs or responses.

Blazor associates errors with the correct nested instance and form field for redisplay. Minimal API automatic parameter binding retains its bad-request behavior for invalid submitted input and does not invoke a normal handler with a success-shaped default; applications wanting partial results use the collecting API explicitly inside their handler. The core result is not MVC ModelState and does not claim that validation has run. Validation can subsequently append errors through host integration without erasing mapping errors.

NoData is not a conversion error. Conversely, an unresolved type, invalid constructor contract, invalid options, or missing strict-mode definition is a configuration failure, not NoData or an empty successful result. Contract failures identify the root and failing member/type and recommend supplying a definition, converter, valid construction choice, or exclusion.

Bound resource use and reject ambiguous input

Applications can bound mapping independently of HTTP request-body parsing.

var readOptions = new FormDataReadOptions
{
    MaxCollectionSize = 1024,
    MaxRecursionDepth = 64,
    MaxErrorCount = 200,
    MaxKeySize = 2048
};

These are the library default limits shown explicitly. MaxCollectionSize applies to every collection and dictionary representation, including repeated scalar values and files; MaxRecursionDepth bounds nested traversal; MaxKeySize bounds a decoded submitted path. MaxErrorCount bounds retained diagnostics, including one terminal truncation diagnostic. Limits must be positive, and a depth limit must permit at least the root and one child. Exceeding a limit makes the operation invalid; it never becomes a silently successful truncated model.

Retain existing behavior: scalar targets use the first submitted value and unknown members are ignored. No duplicate-scalar or unknown-member policy selector is introduced. Collection repetition is not a duplicate scalar error; duplicate converted dictionary keys remain errors. Explicitly ignored properties are never assigned. Hosts keep antiforgery/routing metadata outside selected model data.

Path-prefix checks are segment-aware: selecting Order does not select OrderOther. Exceeding limits in custom converters is subject to the same result and reporting rules. Invalid option values fail before reading input. HTTP field-count, multipart/body-size, and file-size limits remain separate host protections and are not disabled by these options.

Preserve existing applications and expose intentional behavior changes

Before adoption, existing applications keep their supported form-mapping path and documented host defaults. After explicit adoption, the new API's generated and compatibility providers follow one contract semantics; reflection does not mean "use different binding rules."

Intentional new-contract requirements include honoring optional constructor defaults, preserving absent settable-member initializers, reporting constructor ambiguity, applying DataMember.IsRequired without requiring a rename, rejecting sparse numeric input instead of losing later items, scoping nested file collections, and diagnosing malformed dictionary paths. These are not described as byte-for-byte compatibility with the current mapper. Migration documentation must show how to retain old application behavior where it was intentional, or identify a behavior change that requires application action.

Form-owned annotations take effect only on the new contract path. Existing annotation-only models remain usable through the supported legacy path while adopting the new assembly is evaluated. Nothing in this proposal removes a reflection path required by existing applications.

Dominant language
C#
Stars
38.5k
Forks
11.6k
Avg merge
3d 1h
Merged PRs (30d)
289

Getting set up

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/aspnetcore

All issues in dotnet/aspnetcore

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.