In this article, we are going to learn about the top-level statements in C#.
Whenever we start learning a new programming language, the first thing we write is usually a “Hello World” statement. And if we are talking about C#, there’s a lot of required code we need to write to do it. At least that’s how it was.
But a thing that few people knew until the advent of .NET 6 is that, at least since C# 9 (.NET 5), we can write a “Hello World” statement as a one-liner. This works by using top-level statements.
Let’s get started.
What Are Top-Level Statements
Fundamentally, all executable .NET projects need an entry point from which to access the programmed logic. In most cases, this used to mean that we needed to place one Main()
method somewhere in the application. This would tell the compiler exactly where to start.
C# 9 brought that requirement down by introducing top-level statements. This language feature allows us to implicitly set an entry point by writing statements outside of a type declaration. By doing this, we don’t need to explicitly set a Main()
method anymore. Technically, we don’t even need to write a single class in the entire project.
All we need is a single line:
Console.WriteLine("Hello, World!");
No using
directives, no namespace
attribution, no class definition, and, most certainly, no methods.
By using top-level statements, we don’t need any boilerplate at all.
This makes a lot of difference when building simple apps to test language features. First-time programmers won’t have to worry about namespaces and classes before learning fundamental programming concepts like operators, conditionals, loops, and data structures.
What this all means is that C# is now much more newbie-friendly, on par with interpreted languages such as Python and JavaScript.
On the other hand, it’s entirely possible to write complex applications using a collection of top-level statements as an entry point. We can instantiate objects, invoke external methods, write local functions, you name it. We can do that because, behind the scenes, we still do have a Main()
method. It’s just hidden from us.
How Top-Level Statements Work
As we’ve just learned, the implementation of top-level statements works by hiding the Main()
method, instead of eliminating it. But how does this “code magic” operate behind the scenes?
Usually, when programming in C#, each of our source files will contain one or more type declarations. Most of the code will be within these types and their methods, which will correctly encapsulate program logic to be used in other contexts.
Before declaring types, we can also set using
directives and global attributes, in that order.
Consider first that we have a simple Fridge
class that lives in the KitchenEntities
namespace and that just writes something to the console:
namespace KitchenEntities { public static class Fridge { public static void ApplianceFunctionality() { var minutes = Random.Shared.Next(0, 60); Console.WriteLine($"I'll freeze your food in {minutes} minutes!"); ; } } }
To access the Fridge class in the usual way, we’d call it from a Program
class’s Main()
method by using its namespace:
using KitchenEntities; namespace Application { public class Program { static void Main(string[] args) { Fridge.ApplianceFunctionality(); } } }
But that’s not how it works with top-level statements.
To make them work, we must select a specific source file and write one or more statements after all of the using
directives and before any attribute/type definition, as specified by the C# 9 Documentation.
In that case, the compiler will act as if they were inside the Main()
method of the Program
class:
using KitchenEntities; Fridge.ApplianceFunctionality();
Much simpler, right? If we move the Fridge
class to a separate file (as we should), the entry point will have only two lines of code, instead of the entire class/method structure implemented by the first example.
We can also normally access the string args[]
parameter, just like we would in an explicitly declared Main()
. It’s a part of the generated entry-point method’s signature.
Additionally, we can use await
and other asynchronous operators or return an int
instead of void
. The generated signature will dynamically adapt to these use cases:
Returns an int | Returns void | |
Is asynchronous | static Task<int> Main(string[] args) | static Task Main(string[] args) |
Is not asynchronous | static int Main(string[] args) | static void Main(string[] args) |
Things to keep in mind
Now we know most of the inner workings of top-level statements. Still, we must keep in mind that an application can only have a single entry point. This means that if our project contains more than one file with top-level statements, the compiler won’t know which one to start the application from.
Thus, we should only place top-level statements in a single file in the entire project. If we try to do otherwise, the lack of clarity about the entry point will result in inevitable compiler errors.
Also, considering that both using
directives and attribute and type definitions are optional in any given source file, they are also not required for using top-level statements. The only limitations are that you can’t place the statements above the using
directives or below the attribute and type definitions if they are present. This means that a file can contain only statements and it will be valid, as seen in the “Hello World” one-liner.
Another thing to keep in mind is that, in reality, the Program
class and Main()
method aren’t named like that. Their actual names are assigned at compile-time and the source code will not be able to access them directly. This can be a considerable hurdle for things like unit testing, which we’ll see in the next section.
Pros and Cons of Using Top-Level Statements
As we have already seen, the usage of top-level statements can be very good in some cases, but it’s not always a bed of roses. Just like any other feature, we must weigh in the advantages and disadvantages. To make an informed decision on whether to use top-level statements, we must keep some things in mind.
Ease of Use
The possibility of writing statements without having to define a class to contain them means that writing simple logic is easier than ever. We can simply start writing statements in an empty source file and the code will run normally. The compiler will do the heavy lifting for us behind the scenes and implicitly generate all of the basic structure.
Even using
statements are dispensable now in the main file, considering that in C# 10 we now have global using
statements. Most basic templates in .NET 6 make use of this feature together with top-level statements. This makes it as simple as possible to just start writing code. This is handy for both new learners jumping into C# and seasoned developers who want a simple sandbox in which to try new things.
Limitations
There are some limitations to keep in mind when using top-level statements. As we have already seen, each project can only contain one source code file that contains using
statements. Besides that, the implicit Program
class that is generated by the compiler can’t have any methods, which are replaced by local functions in that context.
Also, since the Main()
method and its local functions are not accessible, we can’t test the top-level statements and functions themselves by employing unit tests unless we specifically invoke the main project’s assembly to get its entry point:
var entryPoint = typeof(Program).Assembly.EntryPoint!; entryPoint.Invoke(null, new object[] { Array.Empty<string>() });
It may also be necessary to expose the main project’s internals to the test project if it has no public types. This makes it somewhat more complex to use top-level statements if you intend to run tests targeting the entry point, especially for beginners.
On the other hand, if we need to consider writing tests, then we’re likely to be dealing with a bigger project. As a consequence, we’ll probably also need to separate its classes into single files, in which case we can normally test the individual classes themselves without resorting to this method.
Conclusion
In this article, we’ve learned a lot about Top-level Statements in C#. We’ve talked about what these statements are and how they actually work. We’ve also have seen the benefits and caveats of employing them.