← Engineering articles

Runtime Constructor Analysis

Reflection gives you a constructor signature, not where each argument lands.

C# · runtime analysis8 min readBy José A. Saldaña PérezPublished Updated

The type under analysis

The reference console app ships this pair. Two parameters are forwarded to the base constructor; one of them is also assigned locally.

public class BaseEntity
{
    public Guid Id { get; }
    public string Name { get; }

    protected BaseEntity(Guid id, string name)
    {
        Id = id;
        Name = name;
    }
}

public class User : BaseEntity
{
    public string Email { get; }
    public string Username { get; }

    public User(string email, Guid userId, string userName)
        : base(userId, userName)
    {
        Email = email;
        Username = userName;
    }
}

Reflection does not tell you that userName reaches two public properties, including one declared on the base type, or that userId travels through base(...) into BaseEntity.Id.

For a code generator, those relationships matter. It needs to connect email to Email, preserve both uses of userName, and pass the inherited values through the base call. Matching names alone misses relationships such as userIdId.

Runtime constructor analysis supplies distinctive values, constructs the object once, and inspects its public state. When a property preserves a parameter's sentinel, that parameter is Inferred. The result gives a generator a parameter-to-property map without needing the original C# source. The sample targets data classes with straightforward assignments; it observes values, not every possible execution path.

Field guide

Read this note in three passes

  1. 01
    Observe

    Put a distinctive sentinel in every argument and construct the object once.

  2. 02
    Correlate

    Report the parameter outcome and each property mapping’s confidence and provenance.

  3. 03
    Bound

    Use the observed matches for assignment-based data classes, with base correlation and its limits kept explicit.

Figure 1 · Interactive

Figure 1 · User, one vector

The User : BaseEntity constructor from the reference console app. Every argument already holds a distinctive sentinel; playback only changes which landing points are highlighted. Sentinel strings here are from one run and will differ on yours. The console printer reports outcomes, not these raw values — that transcript is below, locked by UserTranscriptExposesParameterPropertyAndDirectBaseOutcomes.

Round 1 of 3 · email

01 Constructor

public class User : BaseEntity
{
public User(
string email,
Guid userId,
string userName
) : base(userId, userName)
{
Email = email;
Username = userName;
}
}

02 Probe arguments

email"__CA_0_…Aa__"
userIdGuid {b1d9…}
userName"__CA_2_…Aa__"
Instantiate oncethen inspect public values

03 Observed landing points

EmailUser"__CA_0_…Aa__"
UsernameUser"__CA_2_…Aa__"
IdBaseEntity · direct baseGuid {b1d9…}
NameBaseEntity · direct base"__CA_2_…Aa__"
Parameter outcome · Inferred

Distinct value, matching destinations

  • email → User.EmailConfidence: Exact · Provenance: ExactSentinel
Exact property mappings

Observed flow, kept apart from base attribution

emailUser.EmailExact · ExactSentinel
userIdBaseEntity.IdExact · ExactSentinel
userNameBaseEntity.NameExact · ExactSentinel
userNameUser.UsernameExact · ExactSentinel
Sentinel text is one run's distinctive values, so the argument labels differ on every run. Every property mapping shown here is Exact with ExactSentinel provenance. The direct-base rows are a separate result: BaseEntity's get-only properties let the analyzer report both forwarded arguments as Inferred base parameters.

How the probe runs

  1. Build a sentinel for every parameter before invoking anything. Strings, guids, numbers, enums, classes and interfaces each get an assignable distinctive value when the type's domain allows it.
  2. Invoke the selected constructor once with that complete argument vector. The analyzer does not fill unused slots with null, and it does not construct once per parameter.
  3. Read every public, non-indexed instance property. Exact matches use ordinal string equality, value equality for value types, and reference identity for class and interface sentinels. A string that contains the complete sentinel without remaining identical to it is the exceptional heuristic containment path.
  4. Probe the immediate base separately only when it exposes exactly one accessible constructor in total and that constructor has parameters. A shared property links a derived parameter to an ordered base parameter. A get-only auto-property rules out an ordinary overwrite by the derived constructor, so the sample reports an inferred base parameter. Writable properties retain a heuristic candidate. This correlation assumes the base constructor uses each parameter consistently across the two probes; neither verdict changes the original property mapping.

The interactive figure replays that single construction one parameter at a time. Playback only changes which exact landing points are highlighted.

Read the result in three layers

Every parameter receives a ParameterInferenceOutcome:

Outcome Meaning
Inferred One or more property mappings preserved the sentinel.
Unmatched The sentinel was supported and construction succeeded, but no readable property preserved it.
Ambiguous A unique conclusion is unsafe, as with Boolean or colliding finite-domain values.
Unsupported No safe assignable, collision-resistant sentinel exists for that shape.
InstantiationFailed Binding, access, support, or constructor execution failed.
InspectionFailed Construction succeeded, but a public getter threw while the object was inspected.

An Inferred parameter then exposes each property mapping's confidence and provenance. ExactSentinel means the observed property preserved the supplied sentinel exactly. TransformedStringContainment is a separate Heuristic result.

Direct-base attribution is a third result. It asks which base-constructor parameter received a forwarded argument. ReadOnlyBaseSentinel marks an Inferred base parameter with Exact confidence: both probes matched the same get-only base property. Here Exact describes the observed match; the argument position is inferred under the assignment-based constructor assumption. DirectBasePropertyCorrelation marks an Ambiguous candidate with Heuristic confidence: the property overlaps, but the derived constructor could have written it. In both cases the property observation that led there remains exact.

What the console app prints

This is the User section of dotnet run --project src/Demo.ConsoleApp. DemoConsoleOutputTests.UserTranscriptExposesParameterPropertyAndDirectBaseOutcomes locks the block.

Type: User

Constructor Parameters:
  - String email
  - Guid userId
  - String userName

Parameter Flow Analysis:

  Parameter: email (String)
    Parameter outcome: Inferred
    Assigned to properties:
      → User.Email (Confidence: Exact; Provenance: ExactSentinel)

  Parameter: userId (Guid)
    Parameter outcome: Inferred
    Assigned to properties:
      → BaseEntity.Id (Confidence: Exact; Provenance: ExactSentinel)
    Direct-base outcome: Inferred
      → Base parameter [0] id via BaseEntity.Id (Outcome: Inferred; Confidence: Exact; Provenance: ReadOnlyBaseSentinel)

  Parameter: userName (String)
    Parameter outcome: Inferred
    Assigned to properties:
      → BaseEntity.Name (Confidence: Exact; Provenance: ExactSentinel)
      → User.Username (Confidence: Exact; Provenance: ExactSentinel)
    Direct-base outcome: Inferred
      → Base parameter [1] name via BaseEntity.Name (Outcome: Inferred; Confidence: Exact; Provenance: ReadOnlyBaseSentinel)

Properties set in constructor:
  - BaseEntity.Id
  - BaseEntity.Name
  - User.Email
  - User.Username

Open the reference implementation and reproduce that block. Install the .NET 10 SDK first. There is no NuGet package; src/ConstructorAnalysis is the library in this repo, and src/Demo.ConsoleApp is the playground the transcript comes from.

git clone https://github.com/meta-engine/constructor-analysis
cd constructor-analysis
dotnet test constructor-analysis.sln --configuration Release
dotnet run --project src/Demo.ConsoleApp --configuration Release

Read the property rows first, then the narrower base rows. email has no direct-base line because it never reached BaseEntity.

How direct-base candidates are decided

Run the same one-vector probe on BaseEntity's constructor. userName preserves its sentinel in BaseEntity.Name during the User construction; base parameter name preserves its own sentinel in that same reflected property during the base probe. That links userName to base parameter [1] name.

A link alone is not proof. If Name had a setter, the User constructor could have called base(userId, "constant") and then written Name = userName itself, and the two probes would look identical. The analyzer therefore checks the property's shape through reflection. A get-only auto-property has no set accessor and a private initonly compiler-generated backing field, which verifiable code can write only inside a constructor of the declaring type. That rules out an ordinary derived write, so the sample labels the correlation Inferred, Exact, ReadOnlyBaseSentinel. It does not establish how the base constructor behaves for other inputs. A base constructor that conditionally chooses between parameters can invalidate the inferred position even when the property is get-only; the analyzer does not inspect the constructor body to rule that out.

A property with a setter, an init accessor, or a getter over a protected field keeps the heuristic verdict. init accessors are callable from derived constructors, and a protected field can be written from anywhere in the hierarchy, so those shapes stay Ambiguous, Heuristic, DirectBasePropertyCorrelation, and the console app prints them with a ? marker instead of an arrow.

The demo's third example makes the separation visible with misleading names: Employee(..., Role userRole, ...) : base(..., userRole), with AccessLevel = userRole locally. The app reports both Employee.AccessLevel and Person.Role as exact property mappings, then reports base parameter [2] role as an inferred base parameter because Person.Role is get-only.

Where the technique stops

  • Transformed values that drop the sentinel. Case changes and concatenation that retain the complete string can still match heuristically. A transform that removes, splits, reorders, truncates, encodes, or hashes the sentinel remains unmatched.
  • Finite domains. Boolean flow needs a contrast probe to distinguish assignment from a constant. The reference marks booleans and exhausted narrow domains ambiguous. Numeric and enum sentinels can also coincide with a constant in the class, so an exact value match alone does not prove an assignment caused it.
  • Writable inherited state. A base property with a setter, an init accessor, or a protected backing field could have been written by the derived constructor, so its base candidate stays heuristic. The read-only verdict assumes verifiable code; reflection can still write an initonly field.
  • Constructors that reject the generated vector. Failures are reported as InstantiationFailed. The reference implementation does not retry with a different sentinel.
  • Shapes without a safe sentinel. Custom structs and some abstract or delegate references are unsupported. null is never accepted as flow evidence.
  • Observable public state only. Public readable, non-indexed properties are inspected, including computed getters. A throwing getter is reported as InspectionFailed; private getters and fields are not inspected.
  • Side effects run. A constructor that opens a connection opens it. Keep the technique on DTO-shaped types.
  • One constructor, one base level. The analyzer selects the constructor with the most parameters and inspects type.BaseType once. Multiple accessible base constructors make direct-base analysis ambiguous. For A : B : C, flow into C is not traced.

Where it fits

The mapping is useful before rendering begins. A generator can distinguish local assignments from inherited values, retain a parameter that feeds more than one property, and emit names according to the target language's conventions. Templates can render those decisions once the relationships are known; observation supplies information that a constructor signature alone does not contain.

The reference repository isolates the observation technique so it is easy to inspect and adapt. A generator can combine these observations with explicit configuration, then pass the resulting relationships to its target-language renderer.

Change the examples under src/Demo.ConsoleApp/Examples and run the playground again. Start with the data classes you would actually want to generate.

Repro
git clone https://github.com/meta-engine/constructor-analysis
cd constructor-analysis
dotnet test constructor-analysis.sln --configuration Release
dotnet run --project src/Demo.ConsoleApp --configuration Release

Requires the .NET 10 SDK; there is no NuGet package. Prints three worked types; User is the second and is what this page quotes.

Clone the reference implementation ↗