C# File-Local Types and Internal Encapsulation
File-local types, introduced in C# 11, provide a scoping mechanism
that restricts the visibility of a type strictly to the source file
where it is declared. By using the file accessibility
modifier, developers can hide implementation details and prevent naming
collisions across the rest of the project, even within the same
namespace or assembly. This article explains how file-local types work,
their primary use cases, and how they enforce strict internal
encapsulation within a single source file.
What Are File-Local Types?
Prior to C# 11, the most restrictive accessibility modifier for
top-level types was internal, which makes a type accessible
anywhere within the same assembly. Nested types could be declared as
private, but their scope was tied to the enclosing type
rather than the physical file.
The file modifier introduces file-scoped visibility for
top-level types, including classes, structs, interfaces, enums, records,
and delegates:
file class DataFormatter
{
public string Format(string input) => input.Trim().ToUpperInvariant();
}Any code written inside the same source file can instantiate and use
DataFormatter. However, to the compiler and every other
file in the assembly, this type does not exist.
How File-Local Types Enforce Encapsulation
File-local types establish a hard boundary at the file level by modifying how the compiler emits type metadata. Behind the scenes, the C# compiler generates an internal type with an encoded, unique name that includes the file's path or identity, preventing standard symbol resolution from referencing it across files.
1. Isolation from the Assembly
Unlike internal types, which leak implementation details
across all files in a project, a file-local type cannot be accessed by
neighboring classes:
// File: Worker.cs
file class LocalHelper
{
public static void Execute() => Console.WriteLine("Working...");
}
public class Worker
{
public void DoWork() => LocalHelper.Execute(); // Valid
}// File: Consumer.cs
public class Consumer
{
public void Run()
{
// Compiler Error: The type or namespace name 'LocalHelper' could not be found
LocalHelper.Execute();
}
}2. Preventing Naming Collisions
Because file-local types are scoped only to their defining file, multiple files within the same namespace can define file-local types using the exact same name without causing compilation conflicts:
- In
FileA.cs, you can declarefile class Logger { }. - In
FileB.cs, you can also declarefile class Logger { }.
Both types compile independently, maintaining separate implementations without naming ambiguities.
3. Surface Area Restrictions
To guarantee that file-local types do not leak into the broader codebase, C# restricts their usage in public or assembly-visible APIs. A file-local type cannot be:
- Used as a return type, parameter type, or type constraint on any
method, property, or field that has greater visibility than
file. - Inherited by non-file-local types.
- Declared inside another type (they must be top-level).
Key Use Cases
- Source Generators: Roslyn source generators often
need to inject boilerplate helper classes into a project. By using the
filemodifier, generated code avoids naming collisions with existing user types or other generators. - Component-Specific Helpers: When a complex class requires dedicated helpers, state bags, or comparers that serve no purpose elsewhere, making them file-local keeps the global and assembly namespaces clean.
- Refactoring and Migration: Legacy systems can be safely modernized file by file using local helper logic without exposing temporary structures to the rest of the application.