C# has evolved significantly over the years, and each version brings new features aimed at improving readability, maintainability, and developer productivity. With C# 12, one of the more subtle but powerful additions is File-Scoped Types.
Now, if you’re just hearing about this for the first time, you might be wondering: “File-scoped what now? Aren’t types always file-scoped unless they’re in a nested namespace?”
Fair question.
Let’s break down what File-Scoped Types really are, why they matter, how they change the way we think about type visibility and encapsulation in C#, and how you can start using them effectively today.
What Are File-Scoped Types?
In C# 12, file-scoped types refer to a new kind of type visibility—types that are visible only within the file in which they are declared, even if they’re declared as class
, struct
, or interface
without using the private
or internal
modifier.
This might sound familiar if you’ve worked with top-level statements in C# 9 or file-scoped namespaces in C# 10. The idea is the same: reduce boilerplate, enhance encapsulation, and keep things tidy at the file level.
But there’s a twist.
Previously, if you wanted a type to be invisible outside of a file, you’d use internal
or private
in some cases. But this wasn’t always semantically or practically clean. internal
still exposed the type to the entire assembly. private
could only be used in nested type definitions. There was no native way to say:
“Hey compiler, this type should only be visible inside this one file. Period.”
Until now.
With C# 12’s file
modifier, we get exactly that.
Syntax
Here’s how you declare a file-scoped type:
file class MyPrivateHelper
{
public void DoSomethingInternal()
{
Console.WriteLine("Doing something that's nobody else's business.");
}
}
Code language: C# (cs)
In this example, MyPrivateHelper
can only be used within the same file. Not in other classes. Not in other files in the same namespace. Not in the same assembly. Just this one file.
Why Use File-Scoped Types?
This is where things get interesting. Why would you want to do this? What’s the point?
Let’s look at a few very practical reasons:
1. Better Encapsulation
One of the core principles of good software design is encapsulation: hiding unnecessary details from the outside world. You already do this by making methods and fields private
, right?
Now imagine you have a helper class, a builder, or a small data container type that’s only used by one class. Maybe it’s even only used by a single method, but it’s big enough to warrant its own type.
Before C# 12, your only options were:
- Nest it inside another class (which makes the outer class bigger).
- Mark it
internal
and define it in the same namespace, which makes it visible to the whole assembly.
Both approaches have drawbacks. File-scoped types give you a third option: write that helper as a regular class in the same file, without polluting the assembly with types that nobody else should be using.
2. Cleaner Namespaces
We’ve all had that experience: autocomplete in Visual Studio shows a dozen types you didn’t even know existed, because someone defined a bunch of internal classes that are now visible across the project.
File-scoped types reduce namespace pollution. They’re not just internal
, they’re invisible.
3. Faster Code Navigation
If a type is file-scoped, you immediately know that its usage is tightly coupled to the file you’re looking at. This helps you navigate and reason about code faster.
4. Better Separation of Concerns
Instead of cramming all logic into a single class or nesting types awkwardly, file-scoped types encourage cleaner separation without sacrificing encapsulation.
File-Scoped Types vs Other Access Modifiers
Let’s compare file-scoped types to other access modifiers:
Modifier | Scope | Can Be Applied to Top-Level Types? | Use Case |
---|---|---|---|
public | Everywhere | Yes | For shared types used across projects |
internal | Within same assembly | Yes | Shared within the project |
protected | Derived classes | No (only for class members) | For inheritance-related visibility |
private | Declaring class only | No (only for nested types/members) | Tightest control within a class |
file | Current file only | Yes (new in C# 12) | Helper types used in only one file |
So the file
modifier fills a very specific, previously unaddressed niche.
A Practical Example
Let’s look at a realistic scenario.
Suppose you have a file OrderProcessor.cs
, and you’re building logic to process customer orders. You might need a validator, or a parser, or maybe a DTO-like helper structure—but it’s only used internally in this file.
Before C# 12
internal class OrderValidator
{
public bool IsValid(Order order) => order.Quantity > 0;
}
Code language: C# (cs)
Even though you marked it as internal
, any other file in the project could still use OrderValidator
.
With File-Scoped Types (C# 12)
file class OrderValidator
{
public bool IsValid(Order order) => order.Quantity > 0;
}
Code language: C# (cs)
Now, the compiler enforces a strict boundary: OrderValidator
can’t be used outside this file. That’s a cleaner contract.
Real-World Use Cases
Let’s talk scenarios. Here’s when file-scoped types really shine:
1. Helper Classes for a Specific Class
Let’s say you have a ReportGenerator
class, and you need a ReportFormatter
. If the formatter is only used within ReportGenerator.cs
, make it a file-scoped type.
2. One-Off Types for Parsing/Processing
Temporary parsers, tokenizers, or small state machine implementations that support a single feature are great candidates.
3. Private Data Shapes for Serialization
Sometimes you shape data specifically for a file’s needs—say, a model that mimics a JSON payload but doesn’t map directly to your domain model. Don’t leak it into the global scope.
4. Testing Utilities
If you have mock implementations or stubs used only in a test file, make them file-scoped to avoid accidental reuse across other tests.
A Deep Dive Into the Compiler Behavior
So, what happens under the hood when you use file
?
C# compilers treat file
as a unique access modifier. When compiling, the compiler ensures:
- Type name collisions are allowed across files, since the type is invisible outside.
- Other files cannot reference or use the file-scoped type, even if they’re in the same namespace or assembly.
- No metadata exposure: the type doesn’t appear in compiled DLL’s public or internal API surface.
This makes file
the most restrictive access level for top-level types.
But beware—if you use partial classes, file-scoped types cannot participate across multiple files. They’re truly scoped to a single file.
Tips for Using File-Scoped Types Effectively
Here are some guidelines for getting the most out of file-scoped types:
✅ Use When a Type Is Tightly Coupled to File Logic
If your type is only used to support logic in one file, it’s a great candidate.
✅ Use Instead of Nesting If Nesting Gets Too Messy
Nesting types just to hide them can reduce readability. File-scoped types offer an alternative that doesn’t clutter your outer class.
✅ Pair with File-Scoped Namespaces
Using C# 10’s file-scoped namespaces and C# 12’s file-scoped types together can create a beautiful, minimal structure.
namespace MyApp.Processing;
file class Tokenizer { ... }
file class Lexer { ... }
public class Processor { ... }
Code language: C# (cs)
❌ Don’t Use for Types That May Need Reuse
Once a type is marked file
, you can’t use it elsewhere. That’s the point. If there’s even a slight chance it’ll be shared later, consider internal
or private
.
❌ Don’t Overuse It
Not everything needs to be hidden. Use this for truly private or one-off types, not just to “tidy things up.”
Tooling and IDE Support
As of Visual Studio 2022 (and newer versions), file-scoped types are fully supported. IntelliSense respects the scope, syntax highlighting works, and you’ll get compile-time errors if you try to reference file-scoped types across files.
If you use Roslyn analyzers or ReSharper, you may also start seeing recommendations to use file-scoped types when applicable.
Migrating Existing Code
Now you might be thinking: should I go back and refactor my entire codebase?
Probably not. But as you touch files during regular development, keep an eye out for types that:
- Are defined only to support logic in one file
- Are marked
internal
but not reused elsewhere - Could benefit from tighter visibility
Then you can confidently refactor them to be file
-scoped.
Limitations and Gotchas
Like any new feature, file-scoped types have limitations you need to be aware of:
1. No Cross-File partial
Support
You can’t split a file-scoped type into multiple files. If you try, the compiler will complain.
2. No Assembly Exposure
This is by design, but it also means you’ll need to rename the type or move it if you suddenly need broader visibility.
3. Incompatible with Older C# Versions
Trying to use file
in a C# 11 or earlier project will result in errors. Make sure your project targets C# 12 or later.
Absolutely — let’s go deeper and explore how file-scoped types in C# 12 interact with:
- Source Generators
- Performance Profiling
- Advanced Compiler Diagnostics
We’re going beyond the “what” and “why” here and into the “how” and “under the hood” territory. So buckle up — this is where it gets interesting for folks who love tooling, introspection, and squeezing the most out of the language and runtime.
File-Scoped Types + Source Generators
Quick Primer: What Are Source Generators?
Source generators in C# (introduced in .NET 5 / C# 9) let you analyze code during compilation and generate additional C# source files. They’re part of the Roslyn compiler and are used for things like:
- Auto-generating boilerplate code
- Meta-programming
- Code weaving
- Strongly typed APIs from JSON, XML, etc.
How Do File-Scoped Types Interact?
File-scoped types present a visibility boundary. A source generator can still see and analyze them, but it cannot use them in generated code unless it generates code within the same file (which is generally not possible or advisable).
Let’s break it down.
You Can See File-Scoped Types in Generators
public class MyGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
foreach (var syntaxTree in context.Compilation.SyntaxTrees)
{
var semanticModel = context.Compilation.GetSemanticModel(syntaxTree);
var root = syntaxTree.GetRoot();
var fileScopedTypes = root.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.Where(cls =>
cls.Modifiers.Any(m => m.Text == "file"));
foreach (var type in fileScopedTypes)
{
// You can analyze it here
var symbol = semanticModel.GetDeclaredSymbol(type);
context.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor("FILE001", "Found file-scoped type", "Found {0}", "Analyzer", DiagnosticSeverity.Info, true),
type.GetLocation(),
symbol?.Name
));
}
}
}
public void Initialize(GeneratorInitializationContext context) { }
}
Code language: C# (cs)
This works. You can inspect and report on file-scoped types.
But You Cannot Reference Them in Generated Code
Let’s say you try to generate this:
public class Wrapper
{
private fileScopedTypeInstance = new MyFileScopedHelper(); // ❌ This won’t compile
}
Code language: C# (cs)
The compiler will throw an error:
The type 'MyFileScopedHelper' is not accessible in this context because it is file-scoped.
Code language: C# (cs)
Workaround or Best Practice?
Instead of referencing file-scoped types directly, use them as internal implementation details. Structure your generated code to interact through public or internal interfaces, keeping the file-scoped types truly hidden.
Good Practice:
- Let file-scoped types serve internal, per-file logic.
- Let source generators target broader-scope types or generate companions (e.g., partial classes or attributes).
File-Scoped Types + Performance Profiling
File-scoped types do not affect runtime performance directly.
But they can contribute to performance in the following indirect ways:
Cleaner IL and Smaller Assemblies
Since file-scoped types aren’t visible outside their file, the compiler can:
- Emit them with non-public visibility
- Avoid placing them in metadata exposed to the rest of the assembly
- Potentially improve JIT compilation (due to reduced method lookup scope)
Let’s peek at the IL (Intermediate Language) difference.
Example
// FileScopedHelper.cs
file class FileScopedHelper
{
public void DoWork() => Console.WriteLine("Work!");
}
Code language: C# (cs)
Now, compile and inspect with ILSpy or dotPeek.
You’ll see the type marked with:
.class private auto ansi beforefieldinit FileScopedHelper
Code language: C# (cs)
Contrast this with internal
:
.class assembly auto ansi beforefieldinit InternalHelper
Code language: C# (cs)
The private
accessibility reduces the metadata footprint and can help minimize JIT time, especially in large assemblies with hundreds of internal helpers.
Performance Profiling with dotTrace or PerfView
When you run performance profilers (like JetBrains dotTrace or Microsoft PerfView), file-scoped types appear only in the context of their containing method/class. This can reduce noise in trace logs or call graphs.
This becomes incredibly helpful when debugging performance in large enterprise codebases, where thousands of internal types can show up and obscure the real hot paths.
Advanced Compiler Diagnostics and File-Scoped Types
Want to go deeper? Let’s talk about how the compiler processes file-scoped types and how you can get more insight during build time.
Roslyn Diagnostic Analyzers
You can write your own custom Roslyn analyzer to:
- Enforce usage of
file
modifier for certain internal classes - Flag accidental overexposure of helper types
- Ensure tests don’t reference non-test file types
Example: A diagnostic that warns if an internal
class is only used in one file and should be file
.
// Pseudo-code
if (type is internal && UsageCount(type) == 1)
{
ReportDiagnostic(
"Consider marking this type as `file` scoped to improve encapsulation"
);
}
Code language: C# (cs)
These diagnostics can run as part of CI pipelines and help enforce architectural boundaries.
Compiler Switches and Logging
You can compile with detailed output using:
dotnet build /p:EmitCompilerGeneratedFiles=true /p:LangVersion=preview
Code language: C# (cs)
This will show generated files and make it easier to inspect compiler behavior around file
scoped types.
IDE, Analyzer, and Dev Workflow Considerations
✔ Visual Studio / JetBrains Rider
- Syntax highlighting for
file
modifier - Intellisense excludes file-scoped types from autocomplete outside the file
- Go-to-definition (
F12
) will not resolve if you’re in another file CodeLens
will show0 references
from other files
✔ Analyzers
If you’re using Roslyn analyzers (e.g., Microsoft.CodeAnalysis.NetAnalyzers), expect future rules to recommend file
in appropriate cases — especially as adoption increases.
Exercise 1: Roslyn Analyzer to Recommend file
Modifier
We’ll write a Roslyn Diagnostic Analyzer that scans your code for top-level internal
types used only in their own file, and suggests converting them to file
.
This includes:
- Creating a custom diagnostic
- Checking references via
ISymbol
- Verifying usage count
- Reporting a diagnostic when appropriate
Prerequisites
You’ll need:
- .NET SDK 7.0 or later
- Visual Studio 2022 (with “Roslyn SDK” workload)
- Optional: Roslyn SDK Templates
Create your analyzer project:
dotnet new analyzer -n FileScopedTypeAnalyzer
cd FileScopedTypeAnalyzer
Code language: C# (cs)
Analyzer Code
Edit FileScopedTypeAnalyzer.cs
like this:
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace FileScopedTypeAnalyzer
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class FileScopedTypeAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "FILE001";
private static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
"Use 'file' modifier for single-file internal type",
"Internal type '{0}' is only used in this file; consider using 'file' modifier",
"Encapsulation",
DiagnosticSeverity.Info,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}
private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
var namedType = (INamedTypeSymbol)context.Symbol;
// Only interested in internal top-level types
if (namedType.DeclaredAccessibility != Accessibility.Internal ||
namedType.ContainingType != null ||
namedType.Locations.Length == 0)
return;
var location = namedType.Locations[0];
var syntaxTree = location.SourceTree;
if (syntaxTree == null) return;
var references = context.Compilation
.GetSemanticModel(syntaxTree)
.SyntaxTree
.GetRoot()
.DescendantNodes()
.OfType<IdentifierNameSyntax>()
.Where(id => id.Identifier.Text == namedType.Name)
.ToList();
// Used only once (declaration), not referenced elsewhere in project
if (references.Count <= 1)
{
var diagnostic = Diagnostic.Create(Rule, namedType.Locations[0], namedType.Name);
context.ReportDiagnostic(diagnostic);
}
}
}
}
Code language: C# (cs)
Test the Analyzer
Build your solution and reference your analyzer from another test project. Add a type like this:
// Helper.cs
internal class Helper { }
Code language: C# (cs)
You’ll now get:
💡 “Internal type ‘Helper’ is only used in this file; consider using ‘file’ modifier”
🚀 Exercise 2: Profiling JIT with File-Scoped Types
While file-scoped types don’t directly speed up runtime, they can reduce metadata, improve code locality, and help the JIT compiler generate tighter call trees — especially in large projects.
We’ll demonstrate this using a simplified example + PerfView.
🔬 Setup: Simulating Internal Helper Overload
Create a large number of internal classes in a namespace:
// InternalHelpers.cs
namespace InternalLib
{
internal class Helper1 { public int Foo() => 1; }
internal class Helper2 { public int Foo() => 2; }
...
internal class Helper500 { public int Foo() => 500; }
}
Code language: C# (cs)
Then benchmark a method calling one of them:
static void Main()
{
var h = new Helper243();
Console.WriteLine(h.Foo());
}
Code language: C# (cs)
Now switch the type to:
// Helper243.cs
file class Helper243 { public int Foo() => 243; }
Code language: C# (cs)
Profiling with PerfView or dotTrace
Use these steps:
PerfView
- Launch
PerfView.exe
- Run your app with JIT + CPU Sampling enabled
- Search for
Helper243.Foo
in the call tree - Check:
- JIT time
- JIT-compiled method count
- Assembly load size
You’ll typically notice:
✅ Slightly smaller method metadata size
✅ Reduced clutter in symbol table
✅ Tighter JIT compilation trees
In massive assemblies (1000s of internal helpers), this translates to measurable startup improvements, especially in ASP.NET Core apps with cold starts.
Final Thoughts
File-scoped types in C# 12 are a deceptively simple feature that can have a huge impact on code quality. They give you:
- Better encapsulation
- Cleaner namespaces
- Tighter control over type visibility
- More maintainable, modular files
They’re not flashy, but they’re powerful. Like many great features in C#, they’re about giving you more control and better defaults.
The beauty of this feature lies in its restraint—it’s not about doing more, but about doing less with more intention. Keeping things local. Scoped. Minimal. Clean.
So next time you write a helper type that no one else should care about, do yourself a favor:
file class MyInternalThing { ... }
Code language: C# (cs)
And move on with a cleaner, safer, saner codebase.