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:
| Feature | Version | Removes |
|---|---|---|
Target-typed new | C# 9 | Writing the type twice |
init, records | C# 9 | Boilerplate for immutable data |
| Global usings, file-scoped namespaces | C# 10 | Repeated using lines and one level of indentation |
| Raw string literals | C# 11 | Escaping quotes and backslashes |
required members | C# 11 | Objects created with missing data |
| Primary constructors, collection expressions | C# 12 | Constructor boilerplate, verbose collection creation |
Easy Way to Remember: modern C# is the same language with less typing.
What You Will Learn
- Required members and primary constructors.
- Anonymous types.
- The range operator
..and the index operator^. - Collection expressions
[ ]. - Global using directives and file-scoped namespaces.
- Raw string literals.
- Target-typed
newand improved object initialization. - 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 required | With required |
|---|---|
Missing data is found at run time, as a null | Missing data stops you at compile time, before you run the code |
| Needs a constructor to force values | Works with simple { ... } initializers |
| Easy to add a property and forget to set it everywhere | Every 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 record | Primary constructor in a class or struct | |
|---|---|---|
| Creates public properties | Yes | No |
| Parameters usable in the body | Yes | Yes |
| Typical use | Data | Services 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.Xafter 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
Selectresults. 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.
| Range | Means |
|---|---|
a..b | From index a up to, but not including, b |
a.. | From a to the end |
..b | From 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.
| Index | Means | Same as |
|---|---|---|
^1 | Last item | arr[arr.Length - 1] |
^2 | Second-to-last item | arr[arr.Length - 2] |
^0 | Just 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 way | Collection 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 using | global using | |
|---|---|---|
| Applies to | One file only | Every file in the project |
| Where to put it | Top of each file | Usually one file, such as GlobalUsings.cs |
| Good for | Namespaces one file needs | Namespaces 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
usinglines). - 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 variable | A 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:
| Feature | What it adds |
|---|---|
Object initializer { Prop = value } | Set properties right when the object is created |
init accessor | Properties that can only be set during initialization, then never changed |
required | Properties that must be set when the object is created |
Target-typed new() | No need to repeat the type name |
with expression | Create a copy with some properties changed to new values |
| Nested initializers | Set 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.
| Practice | Example |
|---|---|
| Turn on nullable reference types | <Nullable>enable</Nullable>; use string? for values that can be null |
Prefer records and init for data | record Address(string City, string Pin); |
Use required instead of hoping callers remember | public required string Email { get; init; } |
| Use primary constructors for dependency injection | class OrderService(IOrderRepo repo) |
| Use collection expressions | List<int> ids = [1, 2, 3]; |
| Use pattern matching for decisions | price switch { < 100 => ..., _ => ... } |
| Use file-scoped namespaces and global usings | namespace Shop.Models; |
| Use raw strings for JSON, SQL and regex | """{ "id": 1 }""" |
Use is null / is not null | if (user is null) return; |
Use async/await for I/O | await File.ReadAllTextAsync(path); |
| Keep methods small, names clear | CalculateShipping(), 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
| Mistake | Wrong | Correct | Why |
|---|---|---|---|
Reading the last item with ^0 | arr[^0] | arr[^1] | ^0 is one past the end |
| Expecting an inclusive range end | arr[1..3] for 3 items | arr[1..4] | The end index is excluded |
| Forgetting a required member | new User { Name = "A" } | Set every required property | Compile error CS9035 |
| Returning an anonymous type | return new { Name, Age }; | return new Person(Name, Age); | Callers cannot name the type |
| Primary-constructor param as property | account.owner from outside | Add 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 braces | namespace App; { ... } | namespace App; and no braces | It applies to the whole file |
What to Remember
- Modern C# features remove boilerplate and catch mistakes at compile time.
requiredproperties 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..bslices fromaup to, but not including,b;^1is the last item.- Collection expressions
[1, 2, 3]and the spread..build any collection type. global usingapplies ausingto 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,requiredandwithmake object creation safe. - Use modern features in new code, and tidy old code when you touch it.
Next: Reflection