Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions proposals/top-level-members.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Top-Level Members

Champion issue: https://github.com/dotnet/csharplang/issues/9803

## Summary

Allow some members (methods, operators, extension blocks, and fields) to be declared in namespaces
and make them available when the corresponding namespace is imported.

```cs
// util.cs
namespace MyApp;

void Print(string s) => Console.WriteLine(s);

string Capitalize(this string input) =>
input.Length == 0 ? input : char.ToUpper(input[0]) + input[1..];
```

```cs
// app.cs
#!/usr/bin/env dotnet

using MyApp;

Print($"Hello, {args[0].Capitalize()}!");
```

```cs
// Fields are useful:
namespace MyUtils;

string? cache;

string GetValue() => cache ??= Compute();
```

```cs
// Simplifies extensions:
namespace System.Linq;

extension<T>(IEnumerable<T> e)
{
public IEnumerable<T> AsEnumerable() => e;
}
```

## Motivation

- Avoid boilerplate utility static classes.
- Evolve top-level statements from C# 9.

## Detailed design

- Some members can be declared directly in a namespace (file-scoped or block-scoped).
- Allowed kinds currently are: methods, operators, extension blocks, and fields.
- Existing declarations like classes still work the same, there shouldn't be any ambiguity.
Copy link
Member

@jcouv jcouv Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top-level statements can co-exist with type declarations, but must come first. Is there a similar rule for top-level members?

Would such types be in the namespace or nested in the TopLevel type? From the spec it should be in the namespace (since types are not allowed as top-level members), but that's surprising. So it may be better to just disallow them...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question, I will add it to the list. But I don't find the behavior surprising, consider that you have an existing code like

namespace N;
class C;

and you decide to add a top-level member, for example

namespace N;
int M() => 42;
class C;

that could be disallowed but if it's allowed it seems natural that the class remains directly in the namespace as it was before.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it's allowed it seems natural that the class remains directly in the namespace as it was before.

That's arguable, but not obvious. What if I sandwich the type?

namespace N;
int M() => 42;
class C;
int M2() => 42;

It doesn't seem obvious why M() and M2() are in N.TopLevel, but C is directly in N

Copy link

@colejohnson66 colejohnson66 Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My $0.02: There's been many a request to "reduce the indentation" with file-scoped classes (like file-scoped namespaces), and the team has rejected them for a variety of reasons — I would too. Combining top-level members with file-scoped types sounds like a recipe for disaster.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jcouv

It doesn't seem obvious why M() and M2() are in N.TopLevel, but C is directly in N

Aren't you taking on a little too much implementation detail here? As far as the user is concerned M and M2 are just functions in the namespace N. The fact that a containing class is generated for them would be--and should be--entirely hidden from the user.

You could even nominally think of each top-level member as being given its own, generated, containing class. As long as the members can still see each other it makes no difference from a usability standpoint.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To put it another way, my mental model of how this should work goes like this:

Rather than thinking "If this file contains top-level members then lift the contents of the file into a generated class" I think "For each declaration in this file, if the declaration is a type then leave it as-is, if it's a member then lift it into a generated class".

So

namespace N;

void M1() {}

class C1 {}

void M2() {}

class C2 {}

void M3() {}

becomes

namespace N;

partial static class TopLevel
{
    void M1() {}
}

class C1 {}

partial static class TopLevel
{
    void M2() {}
}

class C2 {}

partial static class TopLevel
{
    void M3() {}
}

- There is no ambiguity with top-level statements because those are not allowed inside namespaces.

- Top-level members in a namespace are semantically members of an "implicit" class which:
- is `static` and `partial`,
- has accessibility either `internal` (by default) or `public` (if any member is also `public`),
- is named `TopLevel` (can be addressed even from C# which is useful for extension member disambiguation or source-generated `partial` implementations),
- is synthesized per each compilation unit (so having top-level members in the same namespace across assemblies can lead to [ambiguities](#drawbacks)).

For top-level members, this means:
- The `static` modifier is disallowed (the members are implicitly static).
- The default accessibility is `internal`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I definitely don't like this or expect this. I would expect members without modifiers to have the narrowest accessibility (so only visible within the file.

To be visible outside, you'd need to be explicit about internal/public.

Copy link

@Richiban Richiban Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is expected because it's the default visibility for everything that language currently allows to be top-level (classes/structs/delegates/enums).

For example, I think this is pretty unsatisfactory:

// util.cs
namespace MyApp;

// Implicitly `internal`
class MyThing(string _) {}

// Implicitly `private` or `file`
MyThing CreateThing(string s) => new MyThing(s);

Come to think of it, this raises another question: what happens to types declared inside a file that also contains top-level members? Using the example above, we could take it to mean either (a):

// util.cs
namespace MyApp;

static class <>__Generated
{
  class MyThing(string _) {}
  
  // Implicitly `private` or `file`
  MyThing CreateThing(string s) => new MyThing(s);
}

or (b)

// util.cs
namespace MyApp;

// Implicitly `internal`
class MyThing(string _) {}

static class <>__Generated
{
  // Implicitly `private` or `file`
  MyThing CreateThing(string s) => new MyThing(s);
}

Of the two I think (b) makes more sense†, in which case my initial reaction to visibility stands.

† I think (b) is preferrable to (a) because, otherwise, adding a top-level member to a file would suddenly nest all the classes declared in that file, potentially hiding them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think (b) is preferrable to (a) because, otherwise, adding a top-level member to a file would suddenly nest all the classes declared in that file, potentially hiding them.

Agreed, I said that as well in another discussion above (#9719 (comment)).

Although it might just be disallowed to mix existing declarations with the new top-level ones to avoid these kinds of issues.

`public` and `private` is also allowed.
`protected` and `file` is disallowed.
- Overloading is supported.
- `extern` and `partial` are supported.
- XML doc comments work.

- Metadata:
- The implicit class is recognized only if it has name `TopLevel` and an attribute `[TopLevel]` (full attribute name is TBD),
otherwise it is considered a plain old type. This prevents a breaking change
(where new members are in scope which can lead to ambiguity overload resolution errors).

- Usage (if there is an appropriately-shaped `NS.TopLevel` type):
- `using NS;` implies `using static NS.TopLevel;`.
- Lookup for `NS.Member` can find `NS.TopLevel.Member`.
- Nothing really changes for extension member lookup (the class name is already not used for that).

- Entry points:
- Top-level `Main` methods can be entry points.
- Top-level statements are generated into `Program.Main` (speakable function; previously it was unspeakable).
This is a breaking change: there could be a conflict with an existing `Program.Main` method declared by the user.
- Simplify the logic: TLS entry-points are normal candidates (previously they were not considered to be candidates and for example `-main` could not be used to point to them).
This is a breaking change: if the user has custom `Main` methods and top-level statements, they will get an error now because the compiler doesn't know which entrypoint to choose
(to fix that, they can specify `-main`).

## Drawbacks

- Polluting namespaces with loosely organized helpers.
- Requires tooling updates to properly surface and organize top-level methods in IntelliSense, refactorings, etc.
- Entry point resolution breaking changes.

- There might be ambiguities if two assemblies have top-level members in the same namespace:
```cs
// A.dll
namespace X;
public void M1() { } // ok
```
```cs
// B.dll
namespace X;
public void M2() { } // ok
// generated code
namespace X
{
partial class TopLevel // ambiguity error
{
// ...
}
}
```
```cs
// C.dll
using X;
M1(); M2(); // ok
X.TopLevel.M1(); // ambiguity error for `TopLevel` type
```

Since such top-level members would work until one would reference the type,
that could lead to people declaring such conflicting APIs and realizing they are blocked when it's too late (e.g., they have shipped a public API).
The compiler could report a warning but that would still not work if the other DLL reference is added later.

## Alternatives

- Support `args` keyword in top-level members (just like it can be accessed in top-level statements). But we have `System.Environment.GetCommandLineArgs()`.
- Allow capturing variables from top-level statements inside non-`static` top-level members.
Could be used to refactor a single-file program into multi-file program just by extracting functions to separate files.
But it would mean that a method's implementation (top-level statements) can influence what other methods see (which variables are available in top-level members).
- Allow declaring top-level members outside namespaces as well.
- Would introduce ambiguities with top-level statements.
- Could be brought to scope via `extern alias`.
- To avoid needing to specify those in project files (e.g., so file-based apps also work),
there could be a syntax for that like `extern alias Util = Util.dll`.
- Or they could be in scope only in the current assembly.
- Allow declaring top-level statements inside namespaces as well.
- Top-level local functions would introduce ambiguities with top-level methods. Wouldn't be a breaking change though, just need to decide which one wins.
- If we ever allow the `file` modifier on members (methods, fields, etc.), that would be naturally useful for top-level members, too.
`file` members would be scoped to the current file.
Compare that with `private` members which are scoped to the current _namespace_.

## Open questions

- Which member kinds? Methods, fields, properties, indexers, events, constructors, operators.
- Accessibility: what should be the default and which modifiers should be allowed?
- Clustering: currently each namespace per assembly gets its `TopLevel` class.
- Shape of the synthesized static class (currently `[TopLevel] TopLevel`).
- Should we simplify the TLS entry point logic? Should it be a breaking change?
- Should we require the `static` modifier (and keep our doors open if we want to introduce some non-`static` top-level members in the future)?
- Should we disallow mixing top-level members and existing declarations in one file?
- Or we could limit their relative ordering, like top-level statements vs. other declarations are limited today.
- Allowing such mixing might be surprising, for example:
```cs
namespace N;
int s_field;
int M() => s_field; // ok
static class C
{
static int M() => s_field; // error, `s_field` is not visible here
}
```
- Disallowing such mixing might be surprising too, for example, consider there is an existing code:
```cs
namespace N;
class C;
```
and I just want to add a new declaration to it which fails and forces me to create a new file or namespace block:
```cs
namespace N;
extension(object) {} // error
class C;
```
- Do we need new name conflict rules for declarations and/or usages?
For example, should the following be an error when declared (and/or when used)?
```cs
namespace NS;
int Foo;
class Foo { }
```