Modern C# Features

By the Syncfusion DevTutorials team · Last updated

Write less code with fewer mistakes: required members, primary constructors, ranges and indexes, collection expressions, raw strings and more.

Introduction to Modern C# Features

Think about an old car and a new one.

Both get you from home to work. But the new one has power steering, a reversing camera and keyless entry. Nothing it does is impossible in the old car; it is just easier, safer and quicker.

C# has changed the same way. Every year a new version adds small features that remove boring code and prevent common bugs. Code written in C# 12 or 13 often looks very different from C# 6 code, even though it does the same job.

In simple words: modern C# features are shortcuts and safety checks that let you write less code with fewer mistakes.

This lesson collects the features you will see most in modern code:

FeatureVersionRemoves
Target-typed newC# 9Writing the type twice
init, recordsC# 9Boilerplate for immutable data
Global usings, file-scoped namespacesC# 10Repeated using lines and one level of indentation
Raw string literalsC# 11Escaping quotes and backslashes
required membersC# 11Objects created with missing data
Primary constructors, collection expressionsC# 12Constructor boilerplate, verbose collection creation

Easy Way to Remember: modern C# is the same language with less typing.

What You Will Learn

  1. Required members and primary constructors.
  2. Anonymous types.
  3. The range operator .. and the index operator ^.
  4. Collection expressions [ ].
  5. Global using directives and file-scoped namespaces.
  6. Raw string literals.
  7. Target-typed new and improved object initialization.
  8. Modern C# coding practices.

Most examples run in your browser. Global usings and file-scoped namespaces are about how a whole project is organised across several files, so they are shown as plain code.

Start With a Working Example

Press Run.

Modern C# in a few lines

using System;
using System.Collections.Generic;

List<Product> products =
[
    new("Desk lamp", 899m),
    new("Notebook", 120m),
    new("Pen", 25m),
    new("Backpack", 1499m),
];

Product[] cheapest = products.ToArray()[^2..];   // last two items
var summary = new { Count = products.Count, First = products[0].Name };

Console.WriteLine($"{summary.Count} products, first is {summary.First}");
foreach (Product p in cheapest)
    Console.WriteLine(p.Label());

class Product(string name, decimal price)
{
    public string Name { get; } = name;
    public string Label() => $"{name}: Rs. {price}";
}

That small program uses a collection expression ([ ... ]), target-typed new (new("Pen", 25m)), the index and range operators ([^2..]), an anonymous type (new { Count = ... }) and a primary constructor (class Product(string name, decimal price)). The rest of the lesson explains each one.

Try This

Change [^2..] to [..2]. Now the first two products are printed instead of the last two.

Required Members

A required property must be set when the object is created. If you forget it, the code does not compile.

Before C# 11, nothing stopped this:

var user = new User();          // Email forgotten
SendWelcome(user.Email);        // null, discovered only at run time

With required, the compiler catches it immediately:

required forces callers to set the value

using System;

var user = new User { Name = "Asha", Email = "asha@example.com" };
Console.WriteLine($"{user.Name} <{user.Email}>, role {user.Role}");

// var broken = new User { Name = "Ravi" };
// error CS9035: Required member 'User.Email' must be set

class User
{
    public required string Name { get; init; }
    public required string Email { get; init; }
    public string Role { get; init; } = "Member";   // optional, has a default
}
Without requiredWith required
Missing data is found at run time, as a nullMissing data stops you at compile time, before you run the code
Needs a constructor to force valuesWorks with simple { ... } initializers
Easy to add a property and forget to set it everywhereEvery place that creates the object is checked automatically

required works with init and set properties, and with fields. A constructor marked [SetsRequiredMembers] tells the compiler that it sets them all itself.

Primary Constructors

A primary constructor puts the constructor parameters right after the class name. It was added for records in C# 9 and for normal classes and structs in C# 12.

Old style:

public class OrderService
{
    private readonly IEmailSender _email;
    private readonly ILogger _log;

    public OrderService(IEmailSender email, ILogger log)
    {
        _email = email;
        _log = log;
    }
}

With a primary constructor:

public class OrderService(IEmailSender email, ILogger log)
{
    // email and log can be used anywhere inside the class
}

A class with a primary constructor

using System;

var account = new BankAccount("Asha", 1000m);
account.Deposit(250m);
account.Withdraw(100m);
Console.WriteLine(account.Summary());

var greeter = new Greeter("Namaste");
Console.WriteLine(greeter.Greet("Ravi"));

class BankAccount(string owner, decimal openingBalance)
{
    private decimal _balance = openingBalance;     // parameter used to set a field

    public string Owner => owner;                  // parameter used in a property
    public void Deposit(decimal amount) => _balance += amount;
    public void Withdraw(decimal amount) => _balance -= amount;
    public string Summary() => $"{Owner}: {_balance}";
}

class Greeter(string greeting)
{
    public string Greet(string name) => $"{greeting}, {name}!";
}
Primary constructor in a recordPrimary constructor in a class or struct
Creates public propertiesYesNo
Parameters usable in the bodyYesYes
Typical useDataServices that receive their dependencies

In a class, primary constructor parameters are not properties, and they can be changed inside the class. If you need a public property or a value that never changes, assign the parameter to a property or a readonly field, as _balance and Owner do above.

Anonymous Types

An anonymous type is a small object created on the spot without declaring a class. You write new { ... } and list the properties. Once created, you cannot change the properties.

Anonymous types for quick, local results

using System;
using System.Linq;

var point = new { X = 3, Y = 4 };
Console.WriteLine(point);
Console.WriteLine($"X + Y = {point.X + point.Y}");

string[] names = { "Asha", "Ravi", "Meenakshi" };
var lengths = names.Select(n => new { Name = n, Letters = n.Length });

foreach (var item in lengths)
    Console.WriteLine($"{item.Name} has {item.Letters} letters");

var same = new { X = 3, Y = 4 };
Console.WriteLine($"Equal by value: {point.Equals(same)}");

Facts about anonymous types:

  • They are fixed after creation: you cannot change point.X after creation.
  • They compare by value, like records. Two anonymous types with the same properties are equal.
  • They have no name you can write, so you must use var.
  • They are meant for local use, especially LINQ Select results. To return data from a method, use a record or a tuple instead.

Range Operator (..)

The range operator .. extracts a slice (a section) from an array, string or span. You give it a starting position and an ending position.

RangeMeans
a..bFrom index a up to, but not including, b
a..From a to the end
..bFrom the start up to (not including) b
..Everything (the entire collection)

Slicing arrays and strings

using System;

int[] marks = { 55, 67, 72, 81, 90, 98 };

Console.WriteLine(string.Join(", ", marks[1..4]));   // index 1, 2, 3
Console.WriteLine(string.Join(", ", marks[..2]));    // first two
Console.WriteLine(string.Join(", ", marks[4..]));    // from index 4 to the end

string phone = "9876543210";
Console.WriteLine($"Area: {phone[..3]}, rest: {phone[3..]}");

Range middle = 2..5;                                // a Range can be stored
Console.WriteLine(string.Join(", ", marks[middle]));

The end of a range is exclusive (not included), just like i < length in a loop. So marks[1..4] gives you 3 items: at positions 1, 2, and 3. Position 4 is not included.

Index Operator (^)

The index operator ^ counts from the end of a collection. ^1 means the last item, ^2 means the second-to-last item, and so on.

IndexMeansSame as
^1Last itemarr[arr.Length - 1]
^2Second-to-last itemarr[arr.Length - 2]
^0Just past the end (only useful in ranges)arr.Length

You can combine ^ and .. together: [^3..] means "the last three items".

Counting from the end

using System;
using System.Collections.Generic;

string[] queue = { "Asha", "Ravi", "Meena", "Kiran", "Divya" };

Console.WriteLine($"Last in line: {queue[^1]}");
Console.WriteLine($"Second last:  {queue[^2]}");
Console.WriteLine($"Last three:   {string.Join(", ", queue[^3..])}");
Console.WriteLine($"All but first and last: {string.Join(", ", queue[1..^1])}");

string file = "report-2026.pdf";
Console.WriteLine($"Extension: {file[^3..]}");

var list = new List<int> { 10, 20, 30 };
Console.WriteLine($"Last in list: {list[^1]}");

^0 points one position past the last item, so it is only useful in ranges. Using queue[^0] to read a value throws IndexOutOfRangeException; you must use ^1 for the last item.

Collection Expressions

Collection expressions (C# 12) create collections using square brackets [ ... ]. The same syntax works for arrays, lists, sets and more: the compiler automatically builds the type you need.

int[] a = [1, 2, 3];
List<string> b = ["x", "y"];
HashSet<int> c = [1, 2, 2, 3];

The spread element .. takes all items from another collection and puts them into the new one.

One syntax for arrays, lists and sets

using System;
using System.Collections.Generic;

int[] primes = [2, 3, 5, 7];
List<string> fruits = ["apple", "mango"];
HashSet<int> unique = [1, 2, 2, 3, 3, 3];
int[] empty = [];

int[] morePrimes = [.. primes, 11, 13];            // spread: copy all items, then add more
List<string> basket = [.. fruits, "banana", .. new[] { "kiwi" }];

Console.WriteLine(string.Join(", ", primes));
Console.WriteLine($"Unique count: {unique.Count}");
Console.WriteLine($"Empty length: {empty.Length}");
Console.WriteLine(string.Join(", ", morePrimes));
Console.WriteLine(string.Join(", ", basket));

Console.WriteLine(Total([10, 20, 30]));   // pass a collection directly

static int Total(List<int> numbers)
{
    int sum = 0;
    foreach (int n in numbers) sum += n;
    return sum;
}
Old wayCollection expression
new int[] { 1, 2, 3 }[1, 2, 3]
new List<string> { "a", "b" }["a", "b"]
Array.Empty<int>()[]
a.Concat(b).ToArray()[.. a, .. b]

Global Using Directives

Most files in a project start with the same using statements. A global using (C# 10) is written once, in a single file, and applies automatically to every other file in the project.

// File: GlobalUsings.cs
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using MyShop.Models;

Now no other file needs those lines at the top.

Modern project files also turn on implicit usings, which adds the most common namespaces automatically without you writing anything:

<!-- In the .csproj file -->
<ImplicitUsings>enable</ImplicitUsings>

For a console app this includes System, System.IO, System.Linq, System.Collections.Generic, System.Net.Http and System.Threading.Tasks. This is why a new console project can call Console.WriteLine and List<T> without any using lines.

Normal usingglobal using
Applies toOne file onlyEvery file in the project
Where to put itTop of each fileUsually one file, such as GlobalUsings.cs
Good forNamespaces one file needsNamespaces almost every file needs

File-Scoped Namespaces

A file-scoped namespace (C# 10) ends with a semicolon instead of braces. Everything in the file belongs to that namespace, and the whole file needs less indentation.

Old style:

namespace MyShop.Models
{
    public class Product
    {
        public string Name { get; set; } = "";
    }
}

File-scoped:

namespace MyShop.Models;

public class Product
{
    public string Name { get; set; } = "";
}

Rules:

  • Only one file-scoped namespace per file.
  • It must come before any types in the file (after the using lines).
  • It cannot be mixed with top-level statements in the same file, so use it in class files, not in Program.cs.

Almost all new C# code uses file-scoped namespaces. They are shown as plain code here because the playground runs a single file of top-level statements.

Raw String Literals

A raw string literal (C# 11) starts and ends with three or more double quotes """. Inside it, all characters are taken literally: quotes, backslashes and new lines do not need escaping.

They are perfect for JSON, SQL, HTML, regular expressions and Windows file paths.

JSON and paths without escaping

using System;

string oldJson = "{ \"name\": \"Asha\", \"age\": 29 }";

string json = """
    {
      "name": "Asha",
      "age": 29
    }
    """;

string path = """C:\Users\asha\Documents\report.pdf""";

string name = "Ravi";
int score = 92;
string message = $"""
    Student: "{name}"
    Score:   {score}/100
    """;

Console.WriteLine(oldJson);
Console.WriteLine(json);
Console.WriteLine(path);
Console.WriteLine(message);

Rules for multi-line raw strings:

  • The opening """ is followed by a new line, and the closing """ sits on its own line.
  • The indentation of the closing """ is removed from every line, so you can indent the text nicely in your code without changing the output.
  • Put $ in front for interpolation. If the text itself contains { }, use $$""" instead and then write {{value}} for the holes.

Target-Typed new Expressions

When the type is already clear from the left side of the assignment, you can leave it out after new (C# 9). This is called target-typed new.

Dictionary<string, List<int>> scores = new Dictionary<string, List<int>>();  // old
Dictionary<string, List<int>> scores = new();                               // target-typed

new() where the type is obvious

using System;
using System.Collections.Generic;

Dictionary<string, List<int>> scores = new();
scores["Asha"] = new() { 78, 85 };
scores["Ravi"] = new() { 64 };

DateTime start = new(2026, 10, 1);
Order order = new("A-77", start);

PrintOrder(new("B-12", new(2026, 12, 25)));   // the parameter type tells new() what to build

Console.WriteLine($"Asha has {scores["Asha"].Count} scores");
Console.WriteLine($"{order.Id} on {order.Date:dd MMM yyyy}");

static void PrintOrder(Order o) => Console.WriteLine($"Printing {o.Id} for {o.Date:dd MMM}");

record Order(string Id, DateTime Date);
Use var when...Use target-typed new() when...
The right side shows the type clearly: var list = new List<int>();The left side shows the type: fields, properties, parameters
A local variableA field such as private readonly List<Order> _orders = new();

Both remove repetition. Choose whichever keeps the type visible one time.

Improved Object Initialization

Creating objects has become safer and more concise. These features work together:

FeatureWhat it adds
Object initializer { Prop = value }Set properties right when the object is created
init accessorProperties that can only be set during initialization, then never changed
requiredProperties that must be set when the object is created
Target-typed new()No need to repeat the type name
with expressionCreate a copy with some properties changed to new values
Nested initializersSet child object properties and fill collections in one line

Safe, readable object creation

using System;
using System.Collections.Generic;

Invoice invoice = new()
{
    Number = "INV-2026-041",
    Customer = new() { Name = "Asha", City = "Chennai" },
    Lines = { new("Desk lamp", 899m), new("Pen", 25m) },   // adds to the existing list
};

Invoice corrected = invoice with { Number = "INV-2026-041-A" };

Console.WriteLine($"{invoice.Number} for {invoice.Customer.Name} ({invoice.Customer.City})");
Console.WriteLine($"Lines: {invoice.Lines.Count}, total {invoice.Total}");
Console.WriteLine($"Corrected copy: {corrected.Number}");

record Line(string Item, decimal Price);

record Customer
{
    public required string Name { get; init; }
    public string City { get; init; } = "Unknown";
}

record Invoice
{
    public required string Number { get; init; }
    public required Customer Customer { get; init; }
    public List<Line> Lines { get; } = new();
    public decimal Total
    {
        get
        {
            decimal sum = 0;
            foreach (Line l in Lines) sum += l.Price;
            return sum;
        }
    }
}

Lines = { ... } with no new adds items to the list that the property already created. The compiler checks that Number and Customer are set, because they are required.

Modern C# Coding Practices

Modern C# is not just new syntax. It also comes with habits that make code safer and easier to read.

PracticeExample
Turn on nullable reference types<Nullable>enable</Nullable>; use string? for values that can be null
Prefer records and init for datarecord Address(string City, string Pin);
Use required instead of hoping callers rememberpublic required string Email { get; init; }
Use primary constructors for dependency injectionclass OrderService(IOrderRepo repo)
Use collection expressionsList<int> ids = [1, 2, 3];
Use pattern matching for decisionsprice switch { < 100 => ..., _ => ... }
Use file-scoped namespaces and global usingsnamespace Shop.Models;
Use raw strings for JSON, SQL and regex"""{ "id": 1 }"""
Use is null / is not nullif (user is null) return;
Use async/await for I/Oawait File.ReadAllTextAsync(path);
Keep methods small, names clearCalculateShipping(), not Calc2()

Old style and modern style, side by side

using System;
using System.Collections.Generic;
using System.Linq;

// Old style
List<OldPerson> oldPeople = new List<OldPerson>();
oldPeople.Add(new OldPerson("Asha", 29));
oldPeople.Add(new OldPerson("Ravi", 17));
foreach (OldPerson p in oldPeople)
{
    string label;
    if (p.Age >= 18) label = "adult"; else label = "minor";
    Console.WriteLine(p.Name + " is an " + label);
}

// Modern style
List<Person> people = [new("Asha", 29), new("Ravi", 17)];
foreach (var (name, age) in people)
    Console.WriteLine($"{name} is {(age >= 18 ? "an adult" : "a minor")}");

Console.WriteLine($"Adults: {people.Count(p => p is { Age: >= 18 })}");

class OldPerson
{
    public string Name;
    public int Age;
    public OldPerson(string name, int age) { Name = name; Age = age; }
}

record Person(string Name, int Age);

Look closely at the old-style output: "Ravi is an minor". The string-gluing version made a grammar mistake that is easy to miss. The modern version, with interpolation and a pattern, is shorter and reads like the sentence it prints.

You do not need to rewrite old code just to use new features. Use modern features in new code, and when you are already changing a file for another reason, tidy up the part you touch.

Common mistakes

MistakeWrongCorrectWhy
Reading the last item with ^0arr[^0]arr[^1]^0 is one past the end
Expecting an inclusive range endarr[1..3] for 3 itemsarr[1..4]The end index is excluded
Forgetting a required membernew User { Name = "A" }Set every required propertyCompile error CS9035
Returning an anonymous typereturn new { Name, Age };return new Person(Name, Age);Callers cannot name the type
Primary-constructor param as propertyaccount.owner from outsideAdd public string Owner => owner;Class parameters are not properties
Escaping inside raw strings"""C:\\Temp""""""C:\Temp"""Raw strings need no escaping
File-scoped namespace with bracesnamespace App; { ... }namespace App; and no bracesIt applies to the whole file

What to Remember

  • Modern C# features remove boilerplate and catch mistakes at compile time.
  • required properties must be set when an object is created.
  • Primary constructors put parameters after the class name; in classes they are not properties.
  • Anonymous types (new { ... }) are fixed after creation, compare by value, and are for local use.
  • a..b slices from a up to, but not including, b; ^1 is the last item.
  • Collection expressions [1, 2, 3] and the spread .. build any collection type.
  • global using applies a using to every file; file-scoped namespaces (namespace X;) remove a level of indentation.
  • Raw string literals """...""" need no escaping.
  • Target-typed new() removes repeated type names; initializers, init, required and with make object creation safe.
  • Use modern features in new code, and tidy old code when you touch it.

Next: Reflection