📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does IL trimming work in .NET, and how do you make an app or library trim-safe?

ILLink trimming, TrimMode, trim warnings, DynamicallyAccessedMembers and RequiresUnreferencedCode, feature switches and IsTrimmable.

Senior Theory Advanced clr illink nativeaot publish trimming

🎯 Key points

  • Trimming removes statically unreachable code at publish and requires self-contained deployment
  • Reflection by string is not analysable and produces trim warnings
  • Fix with source generators, DynamicallyAccessedMembers or RequiresUnreferencedCode
  • TrimMode full trims everything; partial trims only IsTrimmable assemblies
  • Feature switches remove whole features such as globalisation data
  • Zero trim warnings is the contract; test the trimmed publish

⏱ 30-second answer

Trimming (<PublishTrimmed>true</PublishTrimmed>, self-contained only, and always on for NativeAOT) runs the ILLink trimmer at publish to remove types and members that static analysis proves are unreachable, shrinking the app. Reflection is its enemy: code that finds types or members by string at run time may lose them. The trimmer reports this with warnings such as IL2026 (RequiresUnreferencedCode) and IL2070-series (missing DynamicallyAccessedMembers). You make code trim-safe by using source generators instead of reflection, annotating Type parameters with [DynamicallyAccessedMembers], and marking unavoidable reflection with [RequiresUnreferencedCode]. Libraries declare <IsTrimmable>true</IsTrimmable>.

🗣 Full answer (senior)

How I would answer this in an interview

IL trimming (the ILLink trimmer) removes code that the app doesn't use from the published output — unused types, members, and sometimes whole assemblies — by statically analysing which code is reachable from entry points. It's required for NativeAOT and optional for self-contained apps. The challenge is reflection and dynamic code: the trimmer can't see what you access by name at run time, so you make code trim-safe by avoiding unanalysable patterns, annotating what's needed, and fixing warnings.

1. Enabling it

<PropertyGroup>
  <PublishTrimmed>true</PublishTrimmed>      <!-- self-contained publish; implied by PublishAot -->
  <TrimMode>full</TrimMode>                 <!-- default in .NET 8+: trim everything; 'partial' trims only opted-in assemblies -->
</PropertyGroup>

For libraries: <IsTrimmable>true</IsTrimmable> (or <IsAotCompatible>true</IsAotCompatible>) marks the assembly as trimmable and enables trim analysers during build.

2. How trimming works

  1. Start from roots: the entry point, plus anything marked as roots (TrimmerRootAssembly, descriptors, [DynamicDependency]).
  2. Walk the IL: every method called, type instantiated, field accessed, attribute used is marked as kept.
  3. Follow data flow for reflection patterns it understands: typeof(Order).GetProperties() keeps Order's properties; Type.GetType("Constant.Name") with a constant string is resolved; generic parameters annotated with [DynamicallyAccessedMembers] propagate requirements.
  4. Anything not marked is removed. Remaining reflection that can't be analysed produces warnings (IL2026, IL2070, IL2072, IL2075, IL2087...).

If you ignore warnings, the trimmed app may throw at run time (MissingMethodException, null PropertyInfo, JSON serialiser returning empty objects).

3. What breaks trimming

  • Type.GetType(stringFromConfig), Assembly.GetTypes() scanning, Activator.CreateInstance(type) with unknown types.
  • Reflection-based serialisers (System.Text.Json reflection mode, Newtonsoft.Json), mappers (AutoMapper), DI assembly scanning, MVC/Razor runtime compilation features, BinaryFormatter.
  • dynamic (C# runtime binder), Expression.Compile of reflected members, MakeGenericType with unknown types.
  • Libraries not annotated for trimming.

4. Making an app or library trim-safe

Prefer source generators over reflection:

  • JsonSerializerContext for JSON, [LoggerMessage], [GeneratedRegex], configuration binding generator, RDG for minimal APIs, Mapperly for mapping.

Annotate necessary reflection so requirements flow to callers:

public static T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>()
    => Activator.CreateInstance<T>();

public static void PrintProperties([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type)
{
    foreach (var p in type.GetProperties()) Console.WriteLine(p.Name);   // no warning: trimmer keeps public properties
}

[RequiresUnreferencedCode("Scans assemblies for handlers; not trim-safe.")]
public static void RegisterHandlersByScanning(IServiceCollection services) { ... }   // warning moves to callers

[DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(LegacyPlugin))]
public static void Init() { ... }   // keep specific members
  • [DynamicallyAccessedMembers] on Type parameters, fields, return values and generic parameters tells the trimmer which members to preserve for types flowing there.
  • [RequiresUnreferencedCode] marks APIs that are inherently not trim-safe; callers get warnings.
  • [UnconditionalSuppressMessage] only when you can prove the pattern is safe (with justification).
  • Root descriptors (ILLink.Descriptors.xml) or TrimmerRootAssembly as a last resort to keep whole types/assemblies.

Process:

  1. Enable trim analysers (IsTrimmable, EnableTrimAnalyzer) and get to zero warnings.
  2. Publish trimmed and run integration tests against the published output (not dotnet test on the untrimmed build).
  3. Watch size with tools (e.g. .mstat for AOT, size reports) to confirm benefits.
  4. For libraries, publish with IsTrimmable and include trim tests in CI so consumers can rely on it.

5. A production example

A self-contained desktop tool was trimmed to cut its size from 90 MB to 35 MB. Users then reported that saving settings produced empty JSON files. The settings class was only used via JsonSerializer.Serialize(object) (reflection), and the trimmer removed its property getters' metadata. The build had shown IL2026 warnings that had been ignored. We introduced a JsonSerializerContext for the settings types, fixed the remaining warnings, and added a CI job that runs end-to-end tests against the trimmed publish output.

🔍 What the interviewer is checking

The interviewer checks you can ship small, self-contained or AOT apps safely.

Green flags: reachability analysis, reflection risk, IL2026 and DynamicallyAccessedMembers, source generators, IsTrimmable, feature switches, testing trimmed output.

Red flags: suppresses warnings; thinks trimming is safe for any code; does not know how to annotate.

📚 Learn in depth

1. Analysis granularity

The trimmer works at the IL level across assemblies. In full mode it can remove unused members from all assemblies, including the framework. In partial ("copyused" in older versions), it only trims assemblies marked IsTrimmable. Since .NET 8, most framework libraries are annotated, and full is default.

2. Data-flow analysis for reflection

The trimmer performs intra-procedural data-flow analysis on Type values: if a Type comes from typeof(X) or from an annotated parameter, calls like GetMethod("Name"), GetProperties(), CreateInstance are understood. If a Type comes from an unannotated source (obj.GetType() on an object of unknown type, Type.GetType(variable)), it warns. Annotations make the analysis compositional across methods.

3. Warning categories

Code Meaning
IL2026 Calling a member marked RequiresUnreferencedCode
IL2057 Type.GetType with non-constant string
IL2070/IL2072/IL2075 Type value doesn't satisfy DynamicallyAccessedMembers requirements
IL2080/IL2087/IL2090 Field/generic parameter annotation mismatches
IL2104 Assembly produced trim warnings (when not shown individually)
IL3050 RequiresDynamicCode (AOT-related)

4. Feature switches

The framework uses feature switches (<EventSourceSupport>false</EventSourceSupport>, <UseSystemResourceKeys>true</UseSystemResourceKeys>, <InvariantGlobalization>true</InvariantGlobalization>, <DebuggerSupport>false</DebuggerSupport>) that the trimmer treats as constants, removing entire features' code. These can cut size significantly but change behaviour — opt in deliberately.

5. Testing trimmed apps

  • Run tests against published output (e.g. a test runner executing the published app's endpoints).
  • Use TrimmerSingleWarn=false to see per-call-site warnings instead of per-assembly summaries.
  • Keep a list of reflection-based integrations and verify them specifically.

Try it yourself

  1. Publish a console app that serialises an object via reflection JSON with PublishTrimmed=true and run it.
  2. Fix it with JsonSerializerContext.
  3. Annotate a helper using GetProperties with [DynamicallyAccessedMembers].
  4. Compare sizes with different feature switches.

💻 Code

using System.Diagnostics.CodeAnalysis;

Console.WriteLine(Factory.Create(typeof(EmailHandler)));          // trim-safe: type is statically known
// Console.WriteLine(Factory.CreateByName("EmailHandler"));       // IL2026 at this call site

static class Factory
{
    // The trimmer keeps the public parameterless constructor of any type passed here
    public static object Create(
        [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type)
        => Activator.CreateInstance(type)!;

    // Type name from a string: cannot be analysed, so warn callers
    [RequiresUnreferencedCode("Creates types by name; the type may be removed by trimming.")]
    public static object CreateByName(string typeName)
        => Activator.CreateInstance(Type.GetType(typeName, throwOnError: true)!)!;
}

sealed class EmailHandler
{
    public override string ToString() => "EmailHandler created";
}
<PropertyGroup>
  <PublishTrimmed>true</PublishTrimmed>
  <SelfContained>true</SelfContained>
  <TrimmerSingleWarn>false</TrimmerSingleWarn>
</PropertyGroup>

⚠ Common mistakes

1. Ignoring trim warnings

What happens: builds succeed with IL2xxx warnings that are ignored. Why it is wrong: runtime failures in trimmed apps. Do instead: reach zero warnings; treat them as errors.

2. Testing only untrimmed builds

What happens: unit tests pass on Debug builds. Why it is wrong: trimming removes members only in published output. Do instead: test published trimmed binaries.

3. Blanket suppressions

What happens: suppressing warnings to get clean builds. Why it is wrong: hides real problems. Do instead: annotate or fix; suppress only with justification.

4. Rooting entire assemblies by default

What happens: TrimmerRootAssembly for everything. Why it is wrong: removes most size benefits. Do instead: targeted annotations.

5. Reflection-based serialisation

What happens: JsonSerializer.Serialize(object) in trimmed apps. Why it is wrong: properties may be trimmed. Do instead: source-generated contexts.

6. Assembly scanning for DI

What happens: registering services by scanning types. Why it is wrong: not analysable. Do instead: explicit registration or source-generated registration.

7. Libraries not declaring trimmability

What happens: library authors skip IsTrimmable. Why it is wrong: consumers get warnings or can't trim safely. Do instead: annotate and mark libraries trimmable.

8. Enabling feature switches blindly

What happens: turning off globalisation or event sources. Why it is wrong: behaviour changes (culture formatting, diagnostics). Do instead: choose switches deliberately.

🏛 Senior insight: trade-offs

Trimming forces you to make reflection visible, which is good design pressure.

Pattern Trim-friendly replacement
Reflection JSON JsonSerializerContext source generation
Assembly scanning for DI Explicit registration or a generator
Convention-based mappers Generated or hand-written mapping
Config binding by reflection Configuration binding source generator

A senior view: aim for zero warnings in CI; a project with tolerated warnings is not truly trim-safe.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says NativeAOT always trims Why: Checks the wider AOT restrictions
    How to answer

    NativeAOT (<PublishAot>true</PublishAot>) compiles the whole app, with the parts of the runtime it needs, into one native executable with no JIT and no IL at run time. You get very fast startup, lower memory and a smaller deployment with no .NET runtime to install. The cost is a closed world: trimming is mandatory, Reflection.Emit and loading new assemblies at run time are not available, reflection works only on what the compiler can see, and libraries must be AOT-compatible. ASP.NET Core supports it for minimal APIs and gRPC but not MVC or Razor Pages. Fix the IL2xxx and IL3xxx warnings before shipping.

  2. When: Candidate says reflection is the main risk Why: Probes reflection costs and alternatives
    How to answer

    Reflection reads the metadata the compiler wrote into an assembly at run time: typeof(T) or obj.GetType() gives a Type, and from it you get PropertyInfo, MethodInfo, FieldInfo and so on, filtered by BindingFlags. It is flexible but slow on hot paths: lookups search metadata, Invoke takes an object[], boxes value types, checks arguments and wraps exceptions in TargetInvocationException. Make it fast by looking up once and caching, turning a MethodInfo into a typed delegate, using MethodInvoker (.NET 8) when you only have a MethodInfo, or [UnsafeAccessor] (.NET 8) to reach a private member with no reflection at all. Reflection also hides dependencies from the trimmer, so in trimmed or Native AOT apps you must annotate with [DynamicallyAccessedMembers] or move the work to a source generator.

  3. When: Candidate recommends source generators Why: Checks how generators remove run-time reflection
    How to answer

    A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.

🛠 Real-world problems: how you handle them

Trimmed tool crashed with MissingMethodException only in release
Situation
A self-contained desktop tool was trimmed to reduce download size by [about half].
Signals
Release builds threw `MissingMethodException` when running certain commands; debug builds were fine.
Investigation
Published with `TrimmerSingleWarn=false` and found IL2057 and IL2026 warnings in the command dispatcher that created commands from config strings.
Resolution
Replaced string lookups with a generated dictionary of command factories and annotated the remaining `Type` parameters.
Prevention
Turned trim warnings into errors and added a smoke test against the trimmed publish output.

How to tell it (STAR)

In [our desktop tool], trimming cut the download size but some commands crashed in release only. The trim warnings pointed at our string-based command lookup. I replaced it with generated factories and made trim warnings errors, so the trimmed build became reliable.

↩ Usually asked after

More in CLR Fundamentals

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me