Lexical Structure and Style

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page covers the mechanics that apply to every other page: how the compiler reads your text, how names are scoped, how files are organised, and the naming and layout conventions the rest of this section follows.

Identifiers and Keywords

An identifier starts with a letter or and continues with letters, digits, or a few Unicode categories. C# is case-sensitive, and identifiers are compared after Unicode normalisation.

Reserved keywords (class, if, return, int, …) can never be identifiers. Contextual keywords are keywords only where the grammar expects them, so they remain usable as names elsewhere: var, value, yield, async, await, nameof, when, record, required, file, scoped, and/or/not in patterns, and — new in C# 14 — field and extension.

// `record` and `value` are contextual keywords: still legal as identifiers.
int record = 1;
string value = "still fine";

// A reserved keyword needs the @ prefix to be used as an identifier.
int @class = 3;
string @string = "verbatim identifier";

Console.WriteLine($"{record} {value} {@class} {@string}");

@ identifiers exist mainly for interop: a library written in another .NET language may expose a member called event or params, and @ is how you name it. Do not use @ to dodge a naming conflict you could simply rename away.

C# 14 makes field a keyword inside property accessors, where it refers to the compiler-synthesised backing field. If an existing accessor has a variable named field, that code now changes meaning. Rename the variable, or write @field to keep referring to it. See Classes and Objects.

Literals

Numeric literals accept _ as a digit separator anywhere between digits, and a suffix to pin the type:

int plain      = 1_000_000;
int hex        = 0x00FF_1A2B;
int binary     = 0b1010_0101;

long big       = 9_000_000_000L;
uint unsigned  = 42U;
ulong huge     = 18_000_000_000_000_000_000UL;

float f        = 3.14f;
double d       = 3.141_592_653_589;
decimal money  = 19.99m;          // base-10, 28-29 significant digits -- use for currency
double sci     = 6.022e23;

char c         = 'A';
char tab       = '\t';
char unicode   = '\u00E9';        // é

bool yes       = true;
object? nothing = null;

string plainText   = "escapes: \n \t \\ \"";
string verbatim    = @"C:\temp\no-escapes"; // @ suppresses escape sequences
string raw         = """He said "no escaping needed" and meant it.""";
ReadOnlySpan<byte> utf8 = "UTF-8 bytes"u8;  // a UTF-8 literal, not a string

decimal is the only base-10 floating type and the only correct choice for money; Basic Types and Variables explains why. Raw string literals and u8 literals are covered in Strings and Text.

Comments and XML Documentation

// A single-line comment.

/* A delimited comment.
   It may span lines but does not nest. */

/// <summary>
/// Computes the arithmetic mean of <paramref name="values"/>.
/// </summary>
/// <param name="values">The values to average. Must not be empty.</param>
/// <returns>The arithmetic mean.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="values"/> is empty.</exception>
public static double Average(IReadOnlyCollection<double> values)
{
    if (values.Count == 0)
    {
        throw new ArgumentException("At least one value is required.", nameof(values));
    }

    return values.Sum() / values.Count;
}

/// comments are XML documentation comments. With <GenerateDocumentationFile>true</GenerateDocumentationFile> the compiler extracts them to an XML file that IntelliSense, DocFX and NuGet consume — and starts warning about undocumented public members and broken cref references, which makes them checkable rather than decorative. Coding Conventions and Documentation covers the full tag set.

Statements, Expressions and Blocks

An expression produces a value; a statement performs an action. C# blurs the line deliberately — switch, throw, assignment and await all have expression forms — which is what makes expression-bodied members and concise LINQ possible.

public sealed class Counter
{
    private int _count;

    // Expression-bodied members: `=>` instead of a block.
    public int Count => _count;
    public void Increment() => _count++;
    public override string ToString() => $"Counter({_count})";

    public string Describe(int n) => n switch          // switch *expression*
    {
        < 0 => "negative",
        0   => "zero",
        _   => "positive",
    };
}

A block { … } introduces a scope. A local variable is in scope from its declaration to the end of the enclosing block, and may not be shadowed by another local in a nested block:

public static void Scope()
{
    int x = 1;
    {
        int y = 2;       // visible only inside this block
        Console.WriteLine(x + y);
    }
    // Console.WriteLine(y);  // error CS0103: y is not in scope here
}

Pattern variables and out variables are scoped to the enclosing block from their point of declaration, which is why if (int.TryParse(s, out int n)) { … } works and n remains usable after the if.

Namespaces

A namespace groups types and prevents name collisions. Modern C# uses the file-scoped form — one namespace per file, no extra indentation:

namespace Irurueta.Sample.Geometry;

public readonly record struct Point(double X, double Y);
public readonly record struct Circle(Point Centre, double Radius);

The block form is still valid and is required when a file genuinely needs two namespaces:

namespace Irurueta.Sample.Outer
{
    public class First;

    namespace Inner              // nested -- full name is Irurueta.Sample.Outer.Inner.Second
    {
        public class Second;
    }
}

Namespaces need not match the directory layout, but by convention they do: <Company>.<Product>.<Feature>, matching the default root namespace derived from the project name.

using Directives

using System.Text;                            // plain: bring a namespace into scope
using static System.Math;                     // static: bring a type's static members into scope
using Json = System.Text.Json.JsonSerializer; // alias: rename a type
using Coordinates = (double Lat, double Lon); // alias any type, including tuples (C# 12)

public static class UsingDemo
{
    public static string Run()
    {
        double h = Sqrt(Pow(3, 2) + Pow(4, 2));      // `using static System.Math`
        Coordinates where = (40.4168, -3.7038);
        var sb = new StringBuilder().Append(h).Append(' ').Append(where.Lat);
        return Json.Serialize(sb.ToString());
    }
}

A global using applies to every file in the project and belongs in one dedicated file:

// GlobalUsings.cs
global using System.Collections.Generic;
global using System.Linq;

<ImplicitUsings>enable</ImplicitUsings> — on by default in new projects — makes the SDK add a set of global using directives appropriate to the project type (System, System.Linq, System.Collections.Generic, System.Threading.Tasks and more). This is why templates contain no using lines at all. You can add to or remove from that set in the project file:

<ItemGroup>
  <Using Include="System.Text.Json" />
  <Using Remove="System.Net.Http" />
</ItemGroup>

Directives must precede type declarations, and using directives inside a namespace apply only within it.

Entry Points

Every executable needs exactly one entry point. Either write it explicitly:

namespace EntryPointDemo;

internal static class Program
{
    // Any of: void, int, Task, Task<int>; with or without string[] args.
    private static async Task<int> Main(string[] args)
    {
        await Task.Delay(1);
        return args.Length;
    }
}

…or use top-level statements, in which case the compiler synthesises the class and Main for you. Only one file in a project may have them, args is implicitly available, and await works directly at the top level. Statements must come before any type declaration in that file.

Coding Conventions

Microsoft’s C# coding conventions are what analyzers, dotnet format and this section follow:

Element Casing Example

Namespace, type, method, property, event, enum member

PascalCase

OrderService, TotalPrice

Public / protected field (rare — prefer a property)

PascalCase

MaxRetries

Private / internal field

_camelCase

_retryCount

Static private field

s_camelCase

s_cache

Constant

PascalCase

DefaultTimeout

Parameter, local variable

camelCase

orderId

Interface

I + PascalCase

IOrderRepository

Generic type parameter

T + PascalCase

TKey, TResult

Attribute class

PascalCase + Attribute

ObsoleteAttribute

Async method

PascalCase + Async

LoadOrdersAsync

Layout rules that matter in practice:

  • Allman braces — the opening brace on its own line. (Expression-bodied members and single-line initialisers are the exception.)

  • Four spaces per indent level, no tabs.

  • One type per file, named after the type.

  • using directives at the top, System.* first, outside the namespace.

  • Prefer the language keyword (string, int) over the BCL name (String, Int32).

  • Use var when the type is obvious from the right-hand side; write the type when it is not.

  • Braces even on single-statement if bodies.

All of this is enforceable. Put it in an .editorconfig file and dotnet format will apply it and CI can check it — see Coding Conventions and Documentation.

See Also