AlgoMaster Logo

File-Scoped Types

Low Priority15 min readUpdated June 6, 2026

C# 11 added a new access modifier, file, that you can put in front of a type declaration. A file type is visible only inside the single source file where it's declared. The feature was designed primarily to make life easier for source generators, but it's also a useful tool for keeping private helpers truly private. Note that this lesson is about the file modifier on types, which is a different feature from the file-scoped namespace syntax.

What file Does

Until C# 11, the narrowest visibility you could give a top-level type was internal, which made the type visible everywhere in the same assembly. If two files in the same project both declared internal class Helper, the compiler rejected the second declaration as a duplicate name. The file modifier fixes that. A file type is scoped to one source file. Two files in the same project can both declare file class Helper without conflict.

The PriceCalculator class is declared with the file modifier, so no other source file in the project can see it. The Program class lives in the same file, so it can use the type normally. From the outside, the class effectively does not exist.

This makes file the strictest top-level access in C#. Where private only applies to nested members, file lets a top-level type hide from the rest of the project entirely.

Which Type Kinds Accept file

The file modifier works on every top-level type kind C# supports: classes, structs, records, record structs, interfaces, enums, and delegates. The rules are the same for all of them, so the choice is purely a matter of which type kind fits the job.

Every one of those file declarations is invisible to other files. The same project can have a public record DiscountRule somewhere else with completely different fields, and the two never see each other.

One restriction worth flagging: file only goes on top-level types. You can't write file on a nested type, on a member, or on a local function. The compiler rejects those with error CS9054.

The fix is to lift the type out of its containing class and put it at the top level of the file, where file is legal.

Why the Feature Exists: Source Generators

The file modifier was added with one specific scenario in mind: source generators. A source generator is a Roslyn component that adds C# files to a project at compile time. The user writes some hint (an attribute, a partial class, a JSON file) and the generator produces a .g.cs file with helper types, extension methods, or boilerplate filled in.

The problem before C# 11 was naming. If a generator runs once per source file and emits an internal class Helper each time, two source files in the same project would produce two Helper classes in the same assembly, and the compiler would reject the second one as a duplicate. Generator authors had to invent unique names per file, usually by hashing the source path or embedding GUIDs, which made the generated code ugly and hard to debug.

With file, the generator just emits file class Helper in each generated file. Each generated Helper is scoped to its own generated file, so collisions are impossible. The generator no longer has to worry about names at all.

Two CachedProductRepository classes, two ProductIdRegistry classes, same assembly, no collisions. Before C# 11 this would have been a compile error twice over.

The diagram below shows the difference in shape between an assembly built without file types and one built with them. Each box is a top-level type the compiler has to track.

The boxes on the left have to carry unique suffixes (Helper_A1B2C3, Helper_D4E5F6) so the assembly doesn't have two types with the same name. The boxes on the right use the same short name in every generated file because file makes each occurrence its own type. The generator on the right gets to emit cleaner, shorter, more readable code.

Hand-written code benefits from the same property. If two parts of an e-commerce app each want a small Validator class for their own purposes, both can declare file class Validator and the names won't fight.

Members of a file Type

A file type can declare any kind of member with any access modifier. The file keyword controls the visibility of the type itself, not its members. Inside the type, you write public, private, internal, protected, static, properties, methods, events, constructors, anything you'd put in a normal class.

Members marked public are reachable from anywhere inside the same file. They are not reachable from outside the file, because the enclosing type is not. A public method on a file class is effectively file-scoped in practice. The internal member on a file type follows the same rule. The visibility you write on the member sets an upper bound, and the file scope of the type pulls that bound down to "this file only" from the outside view.

There is no runtime cost. The compiler implements file types by mangling the type's metadata name with a unique suffix tied to the file. Reflection sees those mangled names, but normal C# code never does. There's no extra allocation, no extra dispatch, no measurable difference from declaring the same type as internal.

How the Compiler Implements file Scoping

The CLR has no concept of "file" as a unit of organization. From its point of view, an assembly is a flat list of types. To make file scoping work, the C# compiler renames each file type behind the scenes. The source code says file class Helper, but the metadata that ends up in the assembly says something like <Pricing>F0__Helper, where Pricing is the file name and F0 is a counter. The mangled name is guaranteed to be unique across the assembly, so two files declaring file class Helper end up with two distinct metadata types that just happen to share a friendly name in the source code.

This is the same trick the compiler already used for compiler-generated types like anonymous types and iterator state machines, so the runtime had no work to do for the new feature. Everything is handled in the front-end.

A small consequence is that reflection sees the mangled name. If you do typeof(MyFileType).Name from inside the file, you get back something like <Pricing>F0__MyFileType, not MyFileType. The plain name in source code resolves correctly because the compiler knows the mapping, but tools that walk metadata directly see the encoded form. For most application code this never matters; reflection-heavy frameworks need to account for it.

The two source files on the left both write file class Helper. The compiler in the middle renames each one with a prefix tied to its source file. The assembly on the right contains two distinct types with different metadata names, so the runtime is happy. From inside Pricing.cs, the code still refers to the type as Helper; the compiler does the lookup against <Pricing>F0__Helper automatically.

How file Compares to internal and Nested private

The three narrowest visibility options for hiding a helper type are easy to confuse. The table below lines them up.

ModifierWhere the type livesWho can see itTwo of them with the same name in one project?
internal class HelperTop-level type in a fileEvery file in the same assemblyCompile error
file class HelperTop-level type in a fileOnly code in the same source fileAllowed, one per file
private class Helper nested inside class OuterNested inside another typeOnly code inside OuterAllowed, one per outer type

The choice between them depends on what you want to hide from whom.

Use internal when other files in the same project legitimately need the helper. A shared internal class PricingPolicy in an e-commerce checkout module is a normal case. Other files inside the same assembly use it, anything outside the assembly can't.

Use file when the helper is a private detail of one specific file and no other file should depend on it. Generated code is the obvious example. Hand-written code uses it less often, but it's a clean choice for one-file utility classes that you don't want creeping into the rest of the codebase as everyone discovers them and starts importing them.

Use a nested private type when the helper is conceptually a part of one outer type. A private struct Node inside a LinkedList<T> class is a textbook example. The helper has no meaning outside the containing type and lives next to it for cohesion.

The three helper types each have a different visibility profile. OrderPolicy is reachable from anywhere in the project. CartDiagnostics is reachable only inside this file. CartEntry is reachable only inside Cart. The diagram below visualises the radius of each.

Code in another file inside the same project can reach OrderPolicy. Code anywhere else in the file can reach CartDiagnostics. Code outside Cart cannot reach CartEntry regardless of which file it's in. Code in a different assembly is shut out of all three, which is the expected behaviour given none of them is public.

Cross-File Restrictions

file types are aggressively local, and the compiler stops you from leaking them through their type signatures. A non-file type that's reachable from outside its file cannot use a file type in its public API.

The reasoning is simple: if OrderService is public, code in other files (and other assemblies) can see it, but they cannot see CartLogger. Exposing a CartLogger property would give them a value they can't name, can't store, and can't reason about. The compiler refuses to set up that situation.

The same restriction blocks file types from appearing in:

  • Public, protected, or internal method parameter types and return types on non-file types.
  • Public, protected, or internal fields and properties on non-file types.
  • Base type lists and interface lists on non-file types.

A non-file type cannot inherit from a file base class, and a non-file type cannot implement a file interface. If IPriceAdjuster is declared file interface, then public class TaxAdjuster : IPriceAdjuster is an error.

The fix in each of these cases is one of three things: mark the outer type as file too, mark the inner type as internal or public (and stop using file), or refactor so the file type is purely internal to one method's local scope.

This compiles because both types are file-scoped, so the visibility radius of one matches the other. Nothing outside the file ever sees either of them.

A file type pulls in a small bit of compiler bookkeeping (the mangled name and per-file metadata table), but the runtime overhead is zero. The restriction on public APIs is a compile-time check, not a runtime cost.

Other Limitations

A file type cannot be partial across files. The whole point of partial is that a single type can be split across multiple files; the whole point of file is that the type lives in exactly one file. The two are mutually exclusive, and the compiler enforces it. You can write file partial class Helper if every partial declaration sits in the same file, but each file's partials form their own distinct type.

Both partial declarations live in Pricing.cs, so they combine into one file class Helper inside that file. If the second partial moved to a different file, it would be a separate file class Helper that happens to share a short name.

A few more restrictions:

  • file is not allowed on nested types, members, or local functions. Only top-level types accept it.
  • file types cannot be marked public, protected, internal, or any combination with another access modifier. file is the access modifier and it stands alone.
  • A file type cannot be referenced in an [assembly:] attribute, an InternalsVisibleTo declaration, or any other assembly-level metadata. Its identity is local to a file.
  • A file type cannot be the type of a [ThreadStatic] or other inter-file static state holder if the consumer is non-file. In practice this hardly ever comes up.

The general theme of all these limits is "if the use case requires escaping the file, file is the wrong tool." Pick internal when other files in the same assembly need the type.

Quiz

File-Scoped Types Quiz

10 quizzes