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 declare file class Logger { }.
  • In FileB.cs, you can also declare file 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 file modifier, 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.