Notes from C# Tips and Traps (Incomplete)

Following are the notes that I prepared while viewing course videos from C# Tips and Traps course from Jason Roberts. The code samples should be self-explanatory as they are supported by comments wherever needed. Most of them can compile successfully on LINQPad.

Part I

1.1. Customize debugger display values

  • Override ToString() method.
  • Use DebuggerDisplay attribute.
[DebuggerDisplay("{Name} ({Age} year(s))")] // "Sarah" (50 year(s))
class Person
{
	public string Name { get; set; }

	[DebuggerDisplay("{Age} year(s)"] // 50 year(s)
	public int AgeInYears { get; set; }

	public override string ToString()
	{
		return $"{Name} ({Age} year(s))"); // Sarah (50 year(s))
	}
}

1.2. Control the display of members in the debugger

  • Use DebuggerBrowsable attribute.
class Person
{
	public string Name { get; set; }

	[DebuggerBrowsable(DebuggerBrowsableState.Never)]
	public int AgeInYears { get; set; }

	[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
	public int FavoriteColor { get; set; }
}

1.3. The null-coalescing operator

string userLanguage = null;
string appLanguage = "English";

string language = userLanguage ?? appLanguage ?? "None"; // "English"

1.4. The dangers of virtual method calls from constructor

class BaseClass
{
	public BaseClass
	{
		int length = GetName().Length;
	}

	private virtual string GetName() => "Sarah";
}

class DerivedClass
{
	private override string GetName() => null;
}

new DerivedClass(); // throws NullReferenceException

1.5 The caller information attribute

private Caller()
{
	Callee("Hello");
}

private Callee(
	string message,
	[CallerFilePath] string filePath = null,
	[CallerMemberName] string memberName = null,
	[CallerLineNumber] int lineNumber = 0)
{
	// [C:\..\hello.cs:Caller:42]: Message: Hello
	Debug.WriteLine($"[{filePath}:{memberName}:{lineNumber}]: Message: {message}.");
}
  • Implementing INotifyPropertyChanged interface
class Person : INotifyPropertyChanged
{
	private string name;
	private int age;
	public string Name
	{
		get { return this.name; }
		set { this.name = value; OnPropertyChanged(); }
	}
	public int Age
	{
		get { return this.age; }
		set { this.age = value; OnPropertyChanged(); }
	}
	public event PropertyChangedEventHandler PropertyChanged;
	protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
	{
		PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
	}
}

1.6. Partial types and methods

// Auto-generated partial class.
partial class Person
{
	public string FirstName { get; set; }
	public string LastName { get; set; }

	public string GetFullName()
	{
		string fullName = $"{FirstName} {LastName}";

		// Following line does not get compiled if partial method is not implemented.
		GetFullNameImpl(ref fullName);

		return fullName;
	}

	// Partial bodyless method must have void return type and is implicity private in scope.
	partial void GetFullNameImpl(ref string fullName);
}

// Hand-authored class (stays unaffected even if sister class is regenerated)
partial class Person
{
	// Partial method implementation, though not required for compilation to succeed.
	partial void GetFullNameImpl(ref string fullName)
	{
		fullName = $"{LastName}, {FirstName}";
	}

	// Can define other methods.
	public int GetNumber() => 42;
}

Person p = new Person { FirstName: "Jatin", LastName: "Sanghvi" };
Debug.WriteLine(p.GetFullName()); // Sanghvi, Jatin

1.7. Runtime conversions with Convert.ChangeType

TTarget Convert<TTarget>(object initialValue)
{
	T convertedValue = Convert.ChangeType(initialValue, typeof (T));
}

Convert<int>("99"); // returns 99
Convert<double>("99"); // returns 99.0

1.8. Expose internal types and members to friend assemblies

  • Update AssemblyInfo.cs.
// Refer external assembly by name.
[assembly: InternalsVisibleTo("TestProject")]

// Or refer it by name and public key.
[assembly: InternalsVisibleTo("TestProject, PublicKey=xxxx")]

Part II

2.1. Simplifying string empty and null checking code

string nullString = null, emptyString = string.Empty, whitespaceString = " ", visibleString = "x"; 

string.IsNullOrEmpty(nullString); // true
string.IsNullOrEmpty(emptyString); // true
string.IsNullOrEmpty(whitespaceString); // false

string.IsNullOrWhitespace(whitespaceString); // true
string.IsNullOrWhitespace(visibleString); // false

2.2. Time zones and using DateTime.MinValue to represent null dates

DateTime date = DateTime.MinValue;
date == date.ToLocalTime(); // true for GMT and later time zones, false otherwise

2.3. Conditional compilation and emitting compiler warnings and errors

void Method()
{
#if DEBUG && RELEASE

// Produces build error.
#error Both DEBUG and RELEASE compilation symbols are defined.

#elif DEBUG
	Console.WriteLine("Debug");
#elif RELEASE
	Console.WriteLine("Release");
#else

// Produces build warning. 
#warning None of DEBUG or RELEASE compilation symbols are defined.

	Console.WriteLine("Other");
#endif
}

// Produces output based on available conditional compilation symbols.
Method();

2.4 Testing char Unicode validity

bool isValidCharacter(char character)
{
	// OtherNotAssigned is just one of many UnicodeCategory enum values.
	return char.GetUnicodeCategory(character) != UnicodeCategory.OtherNotAssigned;
}

isValidCharacter('x'); // true
isValidCharacter((char)888) // false

2.5. Changing the current thread’s culture at runtime

var australiaCultureInfo = CultureInfo.GetCultureInfo("en-AU");
Thead.CurrentThread.CurrentCulture = australiaCultureInfo;

Debug.WriteLine("i".ToUpper()); // I
Debug.WriteLine(23.45); // 23.45
Debug.WriteLine(DateTime.Now); // 30/09/2017 9:59:59 PM

var turkeyCultureInfo = CultureInfo.GetCultureInfo("tr-TR");
Thead.CurrentThread.CurrentCulture = turkeyCultureInfo;

Debug.WriteLine("i".ToUpper()); // İ
Debug.WriteLine(23.45); // 23,45
Debug.WriteLine(DateTime.Now); // 30.9.2017 21:59:59

2.6. Creating random numbers

// Following generates same sequence, since r1 and r2 objects were instantiated approximately during same time and hence
// use same seed value.
Random r1 = new Random(), r2 = new Random();
Debug.WriteLine($"{r1.Next()}, {r1.Next()}, {r1.Next()}");
Debug.WriteLine($"{r2.Next()}, {r2.Next()}, {r2.Next()}");

// Following generates all different random numbers.
Random r = new Random();
Debug.WriteLine($"{r.Next()}, {r.Next()}, {r.Next()}");
Debug.WriteLine($"{r.Next()}, {r.Next()}, {r.Next()}");

// Sample code to illustrate random number generation for high security applications.
RandomNumberGenerator provider = RandomNumberGenerator.Create();

byte[] randomBytes = new byte[4];
provider.GetBytes(randomBytes);

int randomInt = BitConverter.ToInt32(randomBytes, 0);
Debug.WriteLine(randomInt);

2.7. Using Tuples to reduce code

var tupleOneElement = new Tuple<int>(1);
var tupleTwoElements = new Tuple<int, string>(1, "hello");

// ArgumentException if the last element of an eight element Tuple is not a Tuple.
var tupleEightElements = new Tuple<int, int, int, int, int, int, int, Tuple<int>>(1, 2, 3, 4, 5, 6, 7, new Tuple<int>(8));

// Using static generic method - Create
var tuple1 = Tuple.Create(2, "howdy");
var tuple2 = Tuple.Create(11, Tuple.Create(2.1, "22"), DateTime.Parse("2017-12-31"));

// Accessing Tuple properties.
Debug.WriteLine(tuple1.Item2.Item1); // 2.1

// Tuples are immutable. Tuple items are read only and cannot be assigned to.
// tuple2.Item1 = 42;

// Comparing Tuples.
var tuple2 = Tuple.Create(11, Tuple.Create(2.1, "22"), DateTime.Parse("2017-12-31"));
Debug.WriteLine(tuple1 == tuple2); // False (Reference equality)
Debug.WriteLine(tuple1.Equals(tuple2)); // True (Value comparison)

// Using Tuples to return multiple values.
Tuple<string, int> GetNameAndScore()
{
	return new Tuple<string, int>("Sarah", 90);
}

var nameAndScore = GetNameAndScore();
Debug.WriteLine($"Name: {nameAndScore.Item1}, Score: {nameAndScore.Item2}.");

2.8. Forcing reference equality comparisons

Uri uri1 = new Uri("https://pluralsight.com");
Uri uri2 = new Uri("https://pluralsight.com");

Debug.WriteLine(object.Equals(uri1, uri2)); // True
Debug.WriteLine(uri1.Equals(uri2)); // True
Debug.WriteLine(uri1 == uri2); // True
Debug.WriteLine(object.ReferenceEquals(uri1, uri2)); // False

uri2 = uri1;
Debug.WriteLine(object.ReferenceEquals(uri1, uri2)); // True

2.9. Don’t change an object’s hashcode after adding to a dictionary

class PersonId
{
	public int Id { get; set; }
	public override int GetHashCode() => Id.GetHashCode();
}

void Main()
{
	Dictionary<PersonId, string> personName = new Dictionary<PersonId, string>();

	PersonId sarah = new PersonId { Id = 1 };
	PersonId john =  new PersonId { Id = 2 };

	personName.Add(sarah, "Sarah");
	personName.Add(john, "John");

	Debug.WriteLine(personName[john]); // John

	// Following causes KeyNotFoundException, since Dictionary lookup will fail with new john's hash code.
	john.Id = 3;
	Debug.WriteLine(personName[john]);
}

2.10. Creating and using combinable enums

// Flag based enums should have plural names.
[Flags]
enum Borders
{
	None = 0,
	Top = 1,
	Right = 2,
	Bottom = 4,
	Left = 8
}

void Main()
{
	// Creating flag combination.
	Borders topRight = Borders.Top | Borders.Right;

	// Combining flag combinations.
	Borders bottomLeft = Borders.Bottom | Borders.Left;
	Borders all = topRight | bottomLeft;

	// Evaluating flag within combination.
	Debug.WriteLine((topRight & Borders.Right) != Borders.None); // True
	Debug.WriteLine(topRight.HasFlag(Borders.Right)); // True

	Debug.WriteLine((topRight | ~Borders.Left) != topRight); // True
	Debug.WriteLine(!topRight.HasFlag(Borders.Left)); // True
}

Part III

3.1. Conditional formatting for positive, negative, and zero numbers

string formatString = "pos<#.##>;neg(#.##);zero";

Debug.WriteLine((23.555).ToString(formatString)); // pos<23.56>
Debug.WriteLine((-23.555).ToString(formatString)); // neg(23.56)
Debug.WriteLine((0.0).ToString(formatString)); // zero

3.2. Marking code as obsolete

[Obsolete]
class ObsoleteClass { }

[Obsolete("Use NewClass from now on")]
class ObsoleteClassWithWarningMessage { }

class NewClass
{
	[Obsolete("Use NewProperty from now on")]
	public string ObsoleteProperty { get; set; }

	public string NewProperty { get; set; }
}

[Obsolete("This class can no longer be used", true)]
class ObsoleteClassWithErrorMessage { }

void Main()
{
	// Generates compilation warning: 'ObsoleteClass' is obsolete.
	new ObsoleteClass();

	// Generates warning: 'ObsoleteClassWithWarningMessage' is obsolete: Use NewClass from now on.
	new ObsoleteClassWithWarningMessage();

	// Generates warning: 'NewClass.ObsoleteProperty' is obsolete: Use NewProperty from now on.
	new NewClass().ObsoleteProperty = "PropertyValue";

	// Generates error: 'ObsoleteClassWithErrorMessage' is obsolete: This class can no longer be used.
	new ObsoleteClassWithErrorMessage();
}

3.3. Avoiding re-evaluation of LINQ queries

  • Before
List<int> numbers = new List<int>() { 1, 2 };
Random random = new Random();

var sequence = numbers.Select(n => new { Number = n, Random = random.Next() });

Debug.WriteLine(sequence.First());
Debug.WriteLine(sequence.First());

// Output
// { Number = 1, Random = 1822957719 }
// { Number = 1, Random = 519386891 }
  • After ( ToList or ToArray )
List<int> numbers = new List<int>() { 1, 2 };
Random random = new Random();

var sequence = numbers.Select(n => new { Number = n, Random = random.Next() }).ToList();

Debug.WriteLine(sequence.First());
Debug.WriteLine(sequence.First());

// Output
// { Number = 1, Random = 209052729 }
// { Number = 1, Random = 209052729 }
  • After ( ToDictionary )
List<int> numbers = new List<int>() { 1, 2 };
Random random = new Random();

var sequence = numbers.ToDictionary(n => n, n => random.Next());

Debug.WriteLine(sequence.First());
Debug.WriteLine(sequence.First());

// Output
// [1, 409999800]
// [1, 409999800]

Monospaced fonts for Visual Studio Code

Firstly, a little history on how I started using Visual Studio Code:

Back in early 2016, I was using Notepad++ for writing daily work notes. Somehow, I felt an urge to see different sections of the text (headings, paragraphs, lists, etc.) in different colors. Storing my notes in Markdown format seemed to be an interesting option. Notepad++ has theming support, but it does not understand Markdown syntax. I started with Microsoft OneNote for first few days but having a text-based editor was always superior to an UI editor, since it does not require switching hands between keyboard and mouse for styling the text and aligning text-sections. In addition, if the text is stored in markdown format, I can use any available text editor to view and edit text. Finally, I ended up on Visual Studio Code since it supported markdown syntax highlighting and it featured HTML preview of the markdown text.

Visual Studio Code on Windows uses Consolas as the default font for text rendering. In my opinion, Consolas is the best monospaced typeface available out-of-the-box in Windows. I have been using Consolas in VS Code for past 18 months and I never thought about changing the default font since Consolas (or any other font) gets rendered superbly in VS Code.

VS Code is built on top of Github’s Electron. Electron is an app runtime for writing native apps that uses Chromium (which Google Chrome is built on) for rendering the interface and Node.js for local APIs. The rendering component of Chromium is called Blink. In short, you get to experience the same crispy clear text in VS Code, as on Google Chrome browser. Microsoft Edge browser also renders text quite nicely, but I do not know why Microsoft cannot offer similar high-quality rendering on other Microsoft products like Word and Visual Studio IDE.

Here’s how Consolas looks like on VS Code:

Consolas Font

Consolas and similarly a lot of monospaced fonts may work great for shell applications like PowerShell, but they are not created specifically with programmers in mind. Instead, there are a selected set of fonts available for free on internet that are possibly better substitutes for Consolas when you are using VS Code primarily for coding. Here are the three fonts I find worth mentioning:

Source Code Pro

Source Code Pro Font

Source Code Pro (download link) makes quite a few changes for developers. For example, it makes it easy for them to distinguish among I, l and 1 characters. The single and double-quotes that are used heavily in programs are enlarged. Vertical placement of symbols like asterisk and hyphens that are used as mathematical operators, is adjusted to align them with nearby operands. It also optimizes shapes of greater-than and less-than symbols to ensure that characters within arrow-operators e.g. -> and => are not misaligned.

Fira Code

Fira Code Font

Fira Code (download link) adds support for programming-ligatures. For example, check how => is printed in line 347 above. The font itself is quite beautiful. The complete list of supported ligatures can be found on Fira Code’s Github repository page. They also have a Wiki page with instructions to enable ligatures in VS Code.

All ligatures available in Fira Code

Luxi Mono

Luxi Mono Font

Luxi Mono (download link) is unique in the sense that it is both monospaced and serif font. It is too quite elegant. Its italic style feels better than those of Source Code Pro and Fira Code.

There are other font alternatives as well. Many of those are mentioned on Fira Code’s README page. One last font worth mentioning is Operator Mono. It is a gem of all fonts though far too heavy on the pocket. All in all, I was using Fira Code in my VS Code editor while I was awe-stricken with its ligatures. Once that phase got over, I switched to Source Code Pro. Having used these fonts, I do not think I will ever be going back to Consolas for VS Code.

LINQ Query Expression with Multiple Generators

Today, I went through the ‘LINQ’ section in C# 7.0 Pocket Reference. The section contains a sub-section on ‘Multiple Generators’ that illustrates how the compiler translates the query expression with multiple generators into a call to SelectMany LINQ query operator. An example from the book would make things clearer:

Query Expression

int[] numbers = { 1, 2, 3 };
string[] letters = { "a", "b" };

IEnumerable<string> query =
  from n in numbers
  from l in letters
  select n.ToString() + 1;

Compiler emitted code

IEnumerable<string> query =
  numbers.SelectMany(
    n => letters,
    (n, l) => select n.ToString() + 1));

In case you are encountering this particular overload of SelectMany operator for first time, it is basically implemented as an extension method that internally contains two foreach loops. The outer loop iterates through the sequence on which the extension method is called. The inner loop iterates through the sequence returned by lambda expression this is passed as SelectMany‘s first argument. SelectMany then calls lambda expression in second argument for each element-pair in outer and inner sequences.

Here is how the method is implemented:

private static IEnumerable<TResult> SelectMany<TSource, TCollection, TResult>( 
  IEnumerable<TSource> source, 
  Func<TSource, IEnumerable<TCollection>> collectionSelector, 
  Func<TSource, TCollection, TResult> resultSelector) 
{ 
  foreach (TSource item in source) 
  { 
    foreach (TCollection collectionItem in collectionSelector(item)) 
    { 
      yield return resultSelector(item, collectionItem); 
    } 
  } 
}

Back to the compiler emitted code, the first lambda expression (copied to collectionSelector  delegate instance) basically ignored its input parameter n and always output the same sequence letters. Hence, in effect we get a Cartesian product of the two sequences. Just for sake of clarity, if the SelectMany query operator did not exist, below would be the non-generic implementation to achieve same effect.

foreach (int item in numbers) 
{ 
  foreach (string collectionItem in letters) 
  { 
    yield return n.ToString() + l;
  } 
}

I wondered what the compiler translates the query expression into if there are three generators present in the query expression as below:

IEnumerable<string> increasings =
  from a in new[] { 1, 2, 3, 4 }
  from b in new[] { 1, 2, 3, 4 }
  from c in new[] { 1, 2, 3, 4 }
  where a < b && b < c
  select a + " " + b + " " + c;
  
increasings.ToList().ForEach(Console.WriteLine);

/*
  Result
 
  1 2 3
  1 2 4
  1 3 4
  2 3 4
*/

I compiled the program and inspected the assembly with help of ILSpy, I could see below code generated by compiler for the above method body:

Private internal class named <>c

private sealed class <>c
{
  public static readonly LinqTest.<>c <>9 = new LinqTest.<>c();

  public static Func<int, IEnumerable<int>> <>9__0_0;

  public static Func<int, int, <>f__AnonymousType0<int, int>> <>9__0_1;

  public static Func<<>f__AnonymousType0<int, int>, IEnumerable<int>> <>9__0_2;

  public static Func<<>f__AnonymousType0<int, int>, int, <>f__AnonymousType1<<>f__AnonymousType0<int, int>, int>> <>9__0_3;

  public static Func<<>f__AnonymousType1<<>f__AnonymousType0<int, int>, int>, bool> <>9__0_4;

  public static Func<<>f__AnonymousType1<<>f__AnonymousType0<int, int>, int>, string> <>9__0_5;

  internal IEnumerable<int> <Main>b__0_0(int a)
  {
    return new int[]
    {
      1,
      2,
      3,
      4
    };
  }

  internal <>f__AnonymousType0<int, int> <Main>b__0_1(int a, int b)
  {
    return new
    {
      a,
      b
    };
  }

  internal IEnumerable<int> <Main>b__0_2(<>f__AnonymousType0<int, int> <>h__TransparentIdentifier0)
  {
    return new int[]
    {
      1,
      2,
      3,
      4
    };
  }

  internal <>f__AnonymousType1<<>f__AnonymousType0<int, int>, int> <Main>b__0_3(<>f__AnonymousType0<int, int> <>h__TransparentIdentifier0, int c)
  {
    return new
    {
      <>h__TransparentIdentifier0,
      c
    };
  }

  internal bool <Main>b__0_4(<>f__AnonymousType1<<>f__AnonymousType0<int, int>, int> <>h__TransparentIdentifier1)
  {
    return <>h__TransparentIdentifier1.<>h__TransparentIdentifier0.a < <>h__TransparentIdentifier1.<>h__TransparentIdentifier0.b && <>h__TransparentIdentifier1.<>h__TransparentIdentifier0.b < <>h__TransparentIdentifier1.c;
  }

  internal string <Main>b__0_5(<>f__AnonymousType1<<>f__AnonymousType0<int, int>, int> <>h__TransparentIdentifier1)
  {
    return string.Concat(new object[]
    {
      <>h__TransparentIdentifier1.<>h__TransparentIdentifier0.a,
      " ",
      <>h__TransparentIdentifier1.<>h__TransparentIdentifier0.b,
      " ",
      <>h__TransparentIdentifier1.c
    });
  }
}

That seems too cryptic but if you see carefully, the member variables within the class can be grouped into three sets:

  1. Six methods named from <Main>b__0_0 to <Main>b__0_5 required while translating the query expression to method calls.
  2. Six Func delegate instances named from <>9__0_0  to <>9__0_5 corresponding to each method.
  3. Public static member named <>9 pointing to the containing class instance.

In our case a and b are outer generator variables and c is inner variable. If outer variables are referenced within the query expression, the compiler needs to ensure that both inner and outer variables are available and hence needs to generate anonymous types that contain all values. That is why we can see two anonymous types in the decompiled code above viz. <>f__AnonymousType0<int, int> and <>f__AnonymousType1<<>f__AnonymousType0<int, int>, int>. It was a kind of revelation to me to find that generic class types are used to for anonymous types in same way how Funcs and Actions are used as delegates for anonymous methods.

Compiler emitted method body

IEnumerable<int> expr_07 = new int[]
{
  1,
  2,
  3,
  4
};
Func<int, IEnumerable<int>> arg_50_1;
if ((arg_50_1 = LinqTest.<>c.<>9__0_0) == null)
{
  arg_50_1 = (LinqTest.<>c.<>9__0_0 = new Func<int, IEnumerable<int>>(LinqTest.<>c.<>9.<Main>b__0_0));
}
var arg_50_2;
if ((arg_50_2 = LinqTest.<>c.<>9__0_1) == null)
{
  arg_50_2 = (LinqTest.<>c.<>9__0_1 = new Func<int, int, <>f__AnonymousType0<int, int>>(LinqTest.<>c.<>9.<Main>b__0_1));
}
var arg_93_0 = expr_07.SelectMany(arg_50_1, arg_50_2);
var arg_93_1;
if ((arg_93_1 = LinqTest.<>c.<>9__0_2) == null)
{
  arg_93_1 = (LinqTest.<>c.<>9__0_2 = new Func<<>f__AnonymousType0<int, int>, IEnumerable<int>>(LinqTest.<>c.<>9.<Main>b__0_2));
}
var arg_93_2;
if ((arg_93_2 = LinqTest.<>c.<>9__0_3) == null)
{
  arg_93_2 = (LinqTest.<>c.<>9__0_3 = new Func<<>f__AnonymousType0<int, int>, int, <>f__AnonymousType1<<>f__AnonymousType0<int, int>, int>>(LinqTest.<>c.<>9.<Main>b__0_3));
}
var arg_B7_0 = arg_93_0.SelectMany(arg_93_1, arg_93_2);
var arg_B7_1;
if ((arg_B7_1 = LinqTest.<>c.<>9__0_4) == null)
{
  arg_B7_1 = (LinqTest.<>c.<>9__0_4 = new Func<<>f__AnonymousType1<<>f__AnonymousType0<int, int>, int>, bool>(LinqTest.<>c.<>9.<Main>b__0_4));
}
var arg_DB_0 = arg_B7_0.Where(arg_B7_1);
var arg_DB_1;
if ((arg_DB_1 = LinqTest.<>c.<>9__0_5) == null)
{
  arg_DB_1 = (LinqTest.<>c.<>9__0_5 = new Func<<>f__AnonymousType1<<>f__AnonymousType0<int, int>, int>, string>(LinqTest.<>c.<>9.<Main>b__0_5));
}
IEnumerable<string> increasings = arg_DB_0.Select(arg_DB_1);
increasings.ToList<string>().ForEach(new Action<string>(Console.WriteLine));

Again, the code seems difficult to comprehend but we have six local variables that get assigned the six delegate instances defined in class <>c. If they turn out to be null, both local and class member variables are assigned the methods defined in the same class. It would help if someone can comment on why is it required to lazily initialize the class member delegate instances.

Anyhow, I tried to trim away the parts that are not required to understand the translation by removing null checks and replacing the generic types for anonymous classes to non-generic named types. Here is what I was left with in the end:

class AB { public int a; public int b; };
class ABC { public AB ab; public int c; };

void Main()
{
  IEnumerable<int> aList = new int[] { 1, 2, 3, 4 };
  
  IEnumerable<AB> abList = aList.SelectMany(
    a => new int[] { 1, 2, 3, 4 },
    (a, b) => new AB { a = a, b = b });
  
  IEnumerable<ABC> abcList = abList.SelectMany(
    ab => new int[] { 1, 2, 3, 4 },
    (ab, c) => new ABC { ab = ab, c = c });
  
  IEnumerable<string> increasings = abcList
    .Where(abc => abc.ab.a < abc.ab.b && abc.ab.b < abc.c)
    .Select(abc => abc.ab.a + " " + abc.ab.b + " " + abc.c);

  increasings.ToList().ForEach(Console.WriteLine);
}

So everything made sense in the end. The compiler keeps creating anonymous classes nesting previous anonymous/named type into current one until all generators are taken cared of. The outer the variable is in the list of generators, the deeper it becomes the member of the final anonymous type.

To conclude, I have always preferred fluent query syntax over query expression and have managed to successfully avoid the latter in my projects until date. Still if in case, a requirement similar to above arrives, the non-query way would be far too complicated and error prone and will need to be dropped in favor of query expression.