The advent of C# 11 has brought with it a host of new features designed to improve and streamline the language’s functionality. One of these enhancements is the introduction of file-scoped types, a unique concept designed to prevent name collisions among types, especially in larger projects that utilize source generators.

To download the source code for this article, you can visit our GitHub repository.

Let’s take a closer look.

What Defines File-Scoped Types?

In C# 11, a file-scoped type refers to a top-level type that confines its scope and visibility to the file where we declare it. This is achieved using the file keyword, acting as a type modifier. The file modifier restricts a top-level type’s scope and visibility to the file in which it’s declared.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Let’s see how it works in practice:

namespace FileScopedTypesInCSharp;

file class HiddenClass
{
    public string Render()
    {
        return "Rendering file scoped Hidden Class";
    }
}

Here, the HiddenClass class remains visible only within its file. This visibility allows other types in an assembly to adopt the name HiddenClass without triggering a naming collision. To illustrate this, let’s use this type in a different file:

namespace FileScopedTypesInCSharp;

public class Program
{
    public static void Main(string[] args)
    {
        var hiddenClass = new HiddenClass();
        var output = hiddenClass.Render();
        Console.WriteLine(output);
    }
}

Although both the Program class and the HiddenClass class are in the same namespace called FileScopedTypesInCSharp, the code fails to compile with the error:

The type or namespace name 'HiddenClass' could not be found (are you missing a using directive or an assembly reference?)

However, if we move the HiddenClass class to the Program.cs file, the error disappears:

namespace FileScopedTypesInCSharp;

public class Program
{
    public static void Main(string[] args)
    {
        var hiddenClass = new HiddenClass();
        var output = hiddenClass.Render();
        Console.WriteLine(output);
    }
}

file class HiddenClass
{
    public string Render()
    {
        return "Rendering file scoped Hidden Class";
    }
}

This code builds successfully and produces the desired output:

Rendering file scoped Hidden Class

Practical Use of File-Scoped Types

In C# 11, the new access modifier for file-scoped types specifically targets source generator authors. This feature lets authors define types confined to a single file, protecting these types from clashing with others in the same namespace and assembly. Code generators produce code dynamically during compilation, so anticipating the names of existing types in a user’s code base becomes challenging. Using file-scoped types, code generators can generate code that fits seamlessly into the user’s codebase without changing preexisting type names.

To further understand how they can be useful in practice, let’s define a source generator:

using System;
using System.Text;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace SourceGenerator
{
    [Generator]
    public class LoggingGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            context.RegisterImplementationSourceOutput(
                context.SyntaxProvider.CreateSyntaxProvider
                (
                    (syntaxNode, ct) => syntaxNode.IsKind(SyntaxKind.ClassDeclaration), 
                    (syntaxNode, ct) => syntaxNode
                ),
                GenerateLoggerClass
            );
        }

        private void GenerateLoggerClass(SourceProductionContext sourceContext, GeneratorSyntaxContext context)
        {
            var classDeclaration = context.Node as ClassDeclarationSyntax;

            if (classDeclaration != null)
            {
                var className = classDeclaration.Identifier.Text;

                var sourceText = $$"""
                file class {className}Logger
                {{
                    public void Log(string message)
                    {{
                        // Logging implementation omitted    
                    }}
                }}
                """;

                sourceContext.AddSource($"{className}Logger", SourceText.From(sourceText, Encoding.UTF8));
            }
        }
    }
}

In this example, the LoggingGenerator examines each syntax tree in the compilation and identifies all user-declared classes. For each one, it generates a new file-scoped class, serving as that class’s logger. Since the logger class has file scope, it avoids conflicts with any of the user’s existing types. This approach lets the source generator produce code that blends seamlessly with the user’s codebase.

To learn more about source generators you can visit our article on the same topic.

Applicability of file Modifier

The file modifier is not limited to classes; it can be applied to any top-level type:

  • Class
  • Interface
  • Struct
  • Enum
  • Delegate

When a type is declared with the file modifier, it becomes a file-scoped type.

Constraints of File-Scoped Types

File-scoped types offer significant advantages, but they have some limitations. For instance, we cannot assign other accessibility modifiers, such as public and internal to them. This restriction means we cannot declare a type as both file and public, or file and internal at the same time.

Declaring a file-scoped type in the same file under a different namespace makes it inaccessible. However, we can still access it by using its fully qualified name.

Lastly, any class implementing a file-scoped interface must also adopt a file scope. This rule ensures that classes restrict their implementation of an interface to the same file if that interface is only visible within that file.

Conclusion

File-scoped types represent a significant evolution in the C# language. By confining types to their files, this feature can prevent potential name collisions among types, which is particularly beneficial for large projects and source generators. While there are certain constraints, the benefits of file-scoped types far outweigh these minor limitations.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!