What’s new in C# 12: overview

Unicorn Developer
8 min readOct 23, 2023

It’s mid-fall which means a new version of C# is coming soon. It’s time to find out what updates will soon appear in the language. Although C#12 has fewer features than previous versions, it still has some curious ones.

Primary constructors

Here is one of the most notable quality-of-life enhancements — we can create constructor in the class declaration:

class Point(int posX, int posY)
{
private int X = posX;
private int Y = posY;

public bool IsInArea(int minX, int maxX, int minY, int maxY)
=> X <= maxX && X >= minX && Y <= maxY && Y >= minY;
}
// ....
var point = new Point(100, 50);
Console.WriteLine(point.IsInArea(30, 150, 50, 150)); // True

We have to use the constructor — it replaces an empty constructor by default. Moreover, when we add another constructor, we should also add this(….):

class Point(int posX, int posY)
{
private int X = posX;
private int Y = posY;
private Color color;

public Point(int posX, int posY, Color color) : this(posX, posY)
{
this.color = color;
}

// ....
}

Hot topic: now, when we use a standard library, the dependency injection syntax may not be so large.

Instead of several reps of the same thing:

public class AuthorizeService
{
private readonly UserRepository _users;
private readonly PasswordHasher<User> _hasher;

public AuthorizeService(UserRepository repository,
PasswordHasher<User> hasher)
{
_users = repository;
_hasher = hasher;
}

// ....
}

We can make the code more concise:

public class AuthorizeService(UserRepository repository, 
PasswordHasher<User> hasher)
{
private readonly UserRepository _users = repository;
private readonly PasswordHasher<User> _hasher = hasher;

// ....
}

Once again, the hustle and bustle goes with this feature. Constructor parameters can be captured not only by fields and properties but by anything at all. So, we can do something like that:

class Point(int posX, int posY)
{
private int X { get => posX; }
private int Y { get => posY; }

// ....
}

Or like that:

class Point(int posX, int posY)
{
public (int X, int Y) GetPosition()
=> (posX, posY);

public void Move(int dx, int dy)
{
posX += dx;
posY += dy;
}

// ....
}

Or even like that:

class Point(int posX, int posY)
{
private int X = posX; // CS9124
private int Y = posY; // CS9124

public bool IsInArea(int minX, int maxX, int minY, int maxY)
=> posX <= maxX && posX >= minX && posY <= maxY && posY >= minY;
}

Yes, now we can not only inadvertently use a field instead of a property, but we can also use the captured parameter of the constructor instead of a property or field. The compiler will flag such an obvious error as the one above with a warning about parameter capture. Although, we can still use it as a field (but not via the this keyword!):

class Point(int posX, int posY)
{
public int X { get => posX; }
public int Y { get => posY; }

public void Move(int dx, int dy)
{
posX += dx;
posY += dy;
}

// ....
}

The analyzer hasn’t issued any warnings. It gets quite interesting when we replace class with record (where this syntax came from):

record Point(int posX, int posY)
{
public int X { get; } = posX;
public int Y { get; } = posY;

// ....
}
// ....
var point = new Point(10, 20);
Console.WriteLine(point);
// Point { posX = 10, posY = 20, X = 10, Y = 20 }

With a simple keystroke, we doubled the properties. This error is unlikely to be frequent, but the mere (very) chance of making it is unpleasant.

The first case has a compiler warning, but for the second one, the developer should take responsibility for the error. In this case, more specialized tools, like static code analyzers, will help you prevent an error. For example, PVS-Studio has several hundred diagnostic rules for finding code defects in C#. So, our team certainly study this case.

Overall, the feature seems very useful, but it can easily confuse developers (especially newcomers).

The terse syntax to work with collections

Let’s continue the topic: how to enhance a developer’s quality of life. Now the syntax to create the collection shouldn’t be as cumbersome as before. Let’s thank the collection expressions for that:

List<char> empty = [];
List<string> names = ["John", "Mike", "Bill"];
int[] numbers = [1, 2, 3, 4, 5];

If you’re experiencing déjà vu — don’t worry. Indeed, there has been a very similar syntax with braces earlier, but it worked only with arrays:

char[] characters = { 'a', 'b', 'c' };
List<char> characters = { 'a', 'b', 'c' }; // CS0622

Multidimensional arrays have also been enhanced (only jagged arrays, though):

double[][] jagged = [[1.0, 1.5], [2.0, 2.5], [3.0, 3.5, 4.0]];

The features don’t end with the option to drop the clumsy new. The spread operator “..” makes it possible to concatenate collections:

Color[] lightPalette = [Color.Orange, Color.Pink, Color.White];
Color[] darkPalette = [Color.Brown, Color.DarkRed, Color.Black];
Color[] mixedPalette = [.. lightPalette,
Color.Grey,
.. darkPalette];

You’ll have to manually teach your collection to work with this syntax, but it’s no big deal. Just add a method, that takes ReadOnlySpan and returns an instance of its own class, then add the CollectionBuilder attribute to the class:

[CollectionBuilder(typeof(IdCache), nameof(Create))]
public class IdCache : IEnumerable<int>
{
private readonly int[] _cache = new int[50];
public IEnumerator<int> GetEnumerator()
=> _cache.AsEnumerable().GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> _cache.GetEnumerator();

public static IdCache Create(ReadOnlySpan<int> source)
=> new IdCache(source);
public IdCache(ReadOnlySpan<int> source)
{
for (var i = 0; i < Math.Min(_cache.Length, source.Length); i++)
_cache[i] = source[i];
}
}
// ....
var john = _userRepository.Get(x => x.UserName == "john");
var oldUsersIds = _userRepository
.GetMany(x => x.RegistrationDate <= DateTime.Parse("01.01.2020"))
.Select(x => x.Id);
IdCache cache = [.. oldUsersIds, john.Id];

Anonymous function parameters by default

C# 12 has introduced another small enhancement for the anonymous functions. Now the lambda parameters can have the value by default:

var concat = (double x, double y, char delimiter = ',')
=> string.Join(delimiter, x.ToString(enUsCulture), y.ToString(enUsCulture));

Console.WriteLine(concat(5.42, 3.17)); // 5.42,3.17
Console.WriteLine(concat(1.0, 9.98, ':')); // 1:9.98

In addition, now you can use the params keyword with them:

var buildCsv = (params User[] users) =>
{
var sb = new StringBuilder();
foreach (var user in users)
sb.AppendLine(string.Join(",",
user.FirstName,
user.LastName,
user.Birthday.ToString("dd.MM.yyyy")));
return sb.ToString();
};

// ....
Console.WriteLine(buildCsv(john, mary));
// John,Doe,15.04.1997
// Mary,Sue,28.07.1995

Alias for any type

Starting with C# 12, you can use using to alias any type without limitations. So, if you want to fool around, now you can do it:

using NullableInt = int?;
using Objects = object[];
using Vector2 = (double X, double Y);
using HappyDebugging = string;

In many cases, using aliases can play a joke on code (if you’re not working alone :) ). However, there are definitely some helpful use cases. For example, if we had a mess with tuples like this:

public class Square
{
// ....
public (int X, int Y, int Width, int Height) GetBoundaries()
=> new(X, Y, Width, Height);
public void SetBoundaries(
(int X, int Y, int Width, int Height) boundaries) { .... }
}

We can enhance this code:

using Boundaries = (int X, int Y, int Width, int Height);
// ....
public class Square
{
// ....
public Boundaries GetBoundaries()
=> new (X, Y, Width, Height);
public void SetBoundaries(Boundaries boundaries) { .... }
}

Though, such tuples make us think whether they’re needed or not. However, under certain circumstances (or when we refactor the code), these tuples can enhance readability.

Please don’t get carried away. If we use the recently added global modifier, we can make the using directive global. This makes it easier to fill everything with tuples (instead of classic data structures).

I can’t immediately come up with a case where we can use the static analyzer. This means that potential errors will show up later, be subtler, and be harder to find, because there is a problem is in the approach. If you find something interesting, feel free to send code snippets to our team.

The nameof refinement

Now the nameof expression can fully capture the instance class members from static methods, initializers, and attributes. There has been a strange limitation before: for example, it allows us to get the name of the class field itself but not its members:

public class User
{
[Description($"Address format is {
nameof(UserAddress.Street)} {nameof(UserAddress.Building)}")] // CS0120
Address UserAddress { get; set; }
// ....
}

Now there is no such problem, and we can use nameof in all the previously mentioned contexts:

public class User
{
[Description($"Address format is {
nameof(UserAddress.Street)} {nameof(UserAddress.Building)}")]
Address UserAddress { get; set; }

public string AddressFormat { get; } =
$"{nameof(UserAddress.Street)} {nameof(UserAddress.Building)}"; }

public static string GetAddressFormat()
=> $"{nameof(UserAddress.Street)} {nameof(UserAddress.Building)}";
}

Inline arrays

Let’s continue with niche features — useful not to everyone, but still bringing changes to the language. In this case, we’re talking about fixed-size arrays that are placed on the stack in a contiguous memory location. We expect to need them mainly for the AOT compiler and for developers who need to write truly high-performance code. To create such an array, you will need some magic. We need to declare a structure that has a single field that defines the array type. Then, mark it with the InlineArray attribute, which specifies the array size.

Here’s what it looks like:

[System.Runtime.CompilerServices.InlineArray(5)]
public struct IntBuffer
{
private int _element0;
}
// ....
var buf = new IntBuffer();
for (var i = 0; i < 5; i++)
buf[i] = i;

foreach (var e in buf)
Console.Write(e); // 01234

The code interception

The following feature enables us to intercept calls to methods and change their behavior. This feature is available in preview mode with C# 12. The new syntax is appropriate for source generators, so don’t be surprised by its clunkiness:

var worker = new Worker();
worker.Run("hello"); // Worker says: hello
worker.Run("hello"); // Interceptor 1 says: hello
worker.Run("hello"); // Interceptor 2 says: hello
// ....
class Worker
{
public void Run(string phrase)
=> Console.WriteLine($"Worker says: {phrase}");
}

static class Generated
{
[InterceptsLocation("Program.cs", line: 3, character: 7)]
public static void Intercept1(this Worker worker, string phrase)
=> Console.WriteLine($"Interceptor 1 says: {phrase}");

[InterceptsLocation("Program.cs", line: 4, character: 7)]
public static void Intercept2(this Worker worker, string phrase)
=> Console.WriteLine($"Interceptor 2 says: {phrase}");
}

We intercept by specifying the InterceptsLocation attribute. It should contain the file name, the string positions, and the character on which the method is called.

While there’s a benefit to the AOT compiler as well, the focus is on code generation. For example, we can dream of libraries that make working with aspect-oriented programming easier. Unit testing frameworks sound even more tempting — finally, we can stop creating an interface for every class just to mock it in tests. Anyway, the community is very engaged in discussing this topic, which is very pleasant for me.

In any case, code generators have become an incredibly powerful tool, so the extension of their functionality is wonderful news.

Conclusion

Although at first glance the list of features doesn’t seem huge (especially if we compare it with previous releases), I’m interested in almost all of them, even if sometimes I have some concerns :). To be honest, I haven’t realized all the changes of the last years, and thoughtfully applied them in real-life circumstances. What do you think about previous updates? Let’s discuss them along with the new C# 12.

You can learn more about the feature specification in the documentation. If you wish to read about the features of the previous C# versions, you can read our previous overview articles here:

If you would like to follow me and read about the code quality, subscribe to the PVS-Studio Twitter or the monthly digest of our best most recent articles.

--

--

Unicorn Developer

The developer, the debugger, the unicorn. I know all about static analysis and how to find bugs and errors in C, C++, C#, and Java source code.