Why this post exists

Most people learn C# by reading about it. The ones who get good learn it by writing small programs that fight back. This post is Day 1 of a six-day plan to learn C# deeply: Days 1 and 2 cover the basics, Days 3 and 4 the mid-level concepts, Days 5 and 6 the advanced ones, and then you build one large project that uses all of it.

Day 1 covers the language core: types and conversion, strings, control flow, methods, and collections. For each concept you get the same five steps: what it is, an analogy, a short example, a practice question, and a complete solution with the reasons it is written that way. Try the practice question yourself before you read the solution. That is where the learning happens.

All code targets .NET 8 or later and uses top-level statements, so you can paste each solution into the Program.cs of a new console project (dotnet new console) and run it.

Concept 1: Types, variables, constants, operators and type conversion

What it is. C# is statically typed: every variable has a type that is fixed when you write the code, and the compiler refuses to mix types carelessly. A variable stores a value, a const is a value that can never change, operators (+ - * / % == && ||) combine values, and conversion is how you move a value from one type to another.

The analogy: jars in a kitchen. Each jar has a label and only holds one kind of thing: a flour jar, a sugar jar. Pouring a small jar into a bigger one always works (implicit conversion). Pouring a big jar into a small one may spill, so you must say so out loud (an explicit cast like (int)9.99, which drops the .99). Pouring something unknown into a jar is risky, so you check it first: that is what int.TryParse does. A const is a jar with the label engraved in stone.

int age = 30; // whole number double price = 19.99; // fractional number (binary floating point) decimal balance = 1050.75m; // money: exact base-10 arithmetic (note the m) bool isActive = true; char grade = 'A'; string name = "Asha"; var count = 10; // 'var' lets the compiler infer int; it is still strongly typed const double Pi = 3.14159; // fixed at compile time, can never change int a = 7, b = 2; Console.WriteLine(a / b); // 3 (int / int is integer division) Console.WriteLine(a % b); // 1 (remainder) Console.WriteLine((double)a / b); // 3.5 (cast one side first) Console.WriteLine(0.1 + 0.2 == 0.3); // False (double cannot store 0.1 exactly) Console.WriteLine(0.1m + 0.2m == 0.3m); // True (decimal can) int small = 42; long big = small; // implicit conversion: widening, always safe double d = 9.99; int truncated = (int)d; // explicit cast: you accept the loss, result is 9 Console.WriteLine($"{big} {truncated}"); string[] inputs = { "123", "12x", "" }; foreach (string text in inputs) { if (int.TryParse(text, out int parsed)) Console.WriteLine($"'{text}' -> {parsed}"); else Console.WriteLine($"'{text}' is not a valid int"); } 

What is happening here:

  • int, double, decimal, bool, char and string are the everyday types. Use decimal for money, because double stores binary fractions and cannot represent 0.1 exactly (the 0.1 + 0.2 == 0.3 line prints False).
  • 7 / 2 is 3, not 3.5, because both sides are int. Cast one side to double first.
  • (int)d is an explicit cast: it truncates, it does not round.
  • TryParse returns true or false and hands the result back through an out variable. It never throws on bad input, which is why it is the right tool for anything a user types.

Practice question 1: temperature converter

Build a console app that converts between Celsius, Fahrenheit and Kelvin. It must read the values from console input and reject invalid input without crashing: unknown scale letters, text instead of numbers, and temperatures below absolute zero.

Solution

using System.Globalization; const double AbsoluteZeroKelvin = 0.0; Console.WriteLine("Temperature converter. Type q at any prompt to quit."); while (true) { Console.Write("Convert from (C/F/K): "); string? fromInput = Console.ReadLine(); if (IsQuit(fromInput)) break; if (!TryParseScale(fromInput, out Scale from)) { Console.WriteLine("Please enter C, F or K."); continue; } Console.Write("Value: "); string? valueInput = Console.ReadLine(); if (IsQuit(valueInput)) break; if (!double.TryParse(valueInput, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) { Console.WriteLine("That is not a number."); continue; } double kelvin = ToKelvin(value, from); if (Math.Round(kelvin, 6) < AbsoluteZeroKelvin) { Console.WriteLine("That is below absolute zero, which is physically impossible."); continue; } Console.Write("Convert to (C/F/K): "); string? toInput = Console.ReadLine(); if (IsQuit(toInput)) break; if (!TryParseScale(toInput, out Scale to)) { Console.WriteLine("Please enter C, F or K."); continue; } double result = FromKelvin(kelvin, to); Console.WriteLine($"{value:F2} {from} = {result:F2} {to}"); } static bool IsQuit(string? input) => string.Equals(input?.Trim(), "q", StringComparison.OrdinalIgnoreCase); static bool TryParseScale(string? input, out Scale scale) { switch (input?.Trim().ToUpperInvariant()) { case "C": scale = Scale.Celsius; return true; case "F": scale = Scale.Fahrenheit; return true; case "K": scale = Scale.Kelvin; return true; default: scale = default; return false; } } static double ToKelvin(double value, Scale scale) => scale switch { Scale.Celsius => value + 273.15, Scale.Fahrenheit => (value - 32) * 5 / 9 + 273.15, Scale.Kelvin => value, _ => throw new ArgumentOutOfRangeException(nameof(scale)) }; static double FromKelvin(double kelvin, Scale scale) => scale switch { Scale.Celsius => kelvin - 273.15, Scale.Fahrenheit => (kelvin - 273.15) * 9 / 5 + 32, Scale.Kelvin => kelvin, _ => throw new ArgumentOutOfRangeException(nameof(scale)) }; enum Scale { Celsius, Fahrenheit, Kelvin } 

Sample run:

Temperature converter. Type q at any prompt to quit. Convert from (C/F/K): C Value: 100 Convert to (C/F/K): F 100.00 Celsius = 212.00 Fahrenheit Convert from (C/F/K): F Value: -500 That is below absolute zero, which is physically impossible. Convert from (C/F/K): K Value: x That is not a number. Convert from (C/F/K): q 

Why this is the best way to write it:

  1. Kelvin is the hub. Converting every scale to Kelvin and then from Kelvin means two small formulas per scale instead of six scale-to-scale formulas. Adding Rankine later costs two lines, not a rewrite.
  2. One validation rule covers every scale. Checking kelvin < 0 after converting means you never write separate minimum values (-273.15, -459.67, 0) for each scale.
  3. Math.Round(kelvin, 6) guards against floating-point noise. Converting -459.67 F can land at -0.00000000000003 K, which would wrongly be rejected. Rounding to six places removes that.
  4. TryParse with CultureInfo.InvariantCulture means 36.6 works on every machine, even ones whose regional settings use a comma as the decimal separator. Bad input takes the continue path instead of an exception.
  5. An enum for the scale means the compiler catches a typo like Scale.Kelvn, which a string comparison never would. The switch expressions throw in the _ arm so a future new enum member cannot be silently ignored.
  6. TryParseScale is its own method with an out parameter, the same shape as int.TryParse, so the main loop reads like a list of checks.

Concept 2: Strings, interpolation and StringBuilder

What it is. A string is a sequence of characters. Strings in C# are immutable: no method ever changes a string, every operation such as ToUpper or Replace returns a new one. String interpolation ($"Hello {name}") puts values straight into text. StringBuilder is the tool for building a string piece by piece.

The analogy: a string is a sentence carved in stone. You cannot change a letter; you carve a whole new tablet. Doing that once is fine. Doing it 10,000 times in a loop leaves a pile of discarded tablets for the garbage collector to clear. A StringBuilder is a whiteboard: you write, rub out, and add as much as you like, and you only carve a tablet (ToString()) when you are finished.

using System.Text; string s = "hello"; string t = s.ToUpper(); // creates a NEW string; s is untouched Console.WriteLine($"{s} -> {t}"); // hello -> HELLO string name = "Asha"; int items = 3; decimal total = 59.5m; Console.WriteLine($"{name} bought {items} items for {total:F2}"); // Asha bought 3 items for 59.50 string padded = " C# is fun "; Console.WriteLine(padded.Trim()); // C# is fun Console.WriteLine(padded.Contains("fun")); // True string[] parts = "a,b,c".Split(','); Console.WriteLine(string.Join(" | ", parts)); // a | b | c Console.WriteLine(string.Equals("Hi", "hI", StringComparison.OrdinalIgnoreCase)); // True // Building a string in a loop: use StringBuilder var sb = new StringBuilder(); for (int i = 1; i <= 5; i++) { sb.Append(i).Append(','); } sb.Length--; // drop the trailing comma Console.WriteLine(sb.ToString()); // 1,2,3,4,5 

What is happening here:

  • s.ToUpper() returns a new string and leaves s alone. Forgetting to use the return value is a very common beginner bug.
  • {total:F2} formats to two decimal places. The text after the colon is a format string.
  • string.Equals(..., StringComparison.OrdinalIgnoreCase) is the correct way to compare ignoring case. Avoid a.ToLower() == b.ToLower(), which allocates two throwaway strings.
  • Use StringBuilder when you append in a loop. For a handful of concatenations, plain + or interpolation is simpler and just as fast.

Practice question 2: word-frequency counter and palindrome checker

Write two functions. The first counts how often each word appears in a piece of text, ignoring case and punctuation. The second returns true if a phrase is a palindrome, again ignoring case, spaces and punctuation ("A man, a plan, a canal: Panama!" is one).

Solution

using System.Text; string sample = "A man, a plan, a canal: Panama! The man said: don't panic. The plan, the plan!"; Console.WriteLine("Word frequency:"); foreach (var (word, count) in CountWords(sample).OrderByDescending(p => p.Value).ThenBy(p => p.Key)) { Console.WriteLine($" {word,-8} {count}"); } foreach (string text in new[] { "A man, a plan, a canal: Panama!", "Hello, World", "Was it a car or a cat I saw?" }) { Console.WriteLine($"\"{text}\" is a palindrome: {IsPalindrome(text)}"); } static Dictionary<string, int> CountWords(string text) { var counts = new Dictionary<string, int>(); var word = new StringBuilder(); foreach (char c in text) { if (char.IsLetterOrDigit(c) || c == '\'') { word.Append(char.ToLowerInvariant(c)); } else { AddWord(counts, word); } } AddWord(counts, word); // the last word has no trailing separator return counts; } static void AddWord(Dictionary<string, int> counts, StringBuilder word) { if (word.Length == 0) return; string w = word.ToString(); counts[w] = counts.GetValueOrDefault(w) + 1; word.Clear(); } static bool IsPalindrome(string text) { var cleaned = new StringBuilder(text.Length); foreach (char c in text) { if (char.IsLetterOrDigit(c)) { cleaned.Append(char.ToLowerInvariant(c)); } } int left = 0, right = cleaned.Length - 1; while (left < right) { if (cleaned[left++] != cleaned[right--]) return false; } return true; } 

Output:

Word frequency: a 3 plan 3 the 3 man 2 canal 1 don't 1 panama 1 panic 1 said 1 "A man, a plan, a canal: Panama!" is a palindrome: True "Hello, World" is a palindrome: False "Was it a car or a cat I saw?" is a palindrome: True 

Why this is the best way to write it:

  1. One pass over the text, no punctuation list. Asking char.IsLetterOrDigit(c) is better than listing punctuation to remove, because it also handles characters you did not think of, including accented letters.
  2. One StringBuilder is reused for every word. Clear() empties it without allocating a new one, so there is no string concatenation inside the loop.
  3. counts[w] = counts.GetValueOrDefault(w) + 1 is the standard counting idiom: a word not seen before counts as 0, then becomes 1. No ContainsKey check, no KeyNotFoundException.
  4. The final AddWord call after the loop catches the last word, which has no separator after it. Forgetting this is the classic off-by-one bug in word counters.
  5. The palindrome check uses two pointers moving inwards and stops at the first mismatch. The shortcut of reversing the cleaned string and comparing works too, but it allocates a second string.
  6. A design choice worth knowing: the apostrophe is kept inside words so "don't" stays one word. The price is that a word wrapped in single quotes keeps its quotes. Decide that rule on purpose and write it down.

Concept 3: Control flow: if, switch, for, foreach and while

What it is. Control flow decides which code runs and how many times. if/else chooses between paths. switch chooses between many paths based on one value, and the modern switch expression returns a value. for, foreach and while repeat code.

The analogy: driving. if is a fork in the road. switch is the signboard at a big junction listing every exit. A for loop is laps round a track with a lap counter. foreach is walking down a street visiting every house. while is waiting at a red light: you stay until the condition changes. break is leaving the roundabout early and continue is skipping this house and moving to the next.

int score = 72; // if / else if / else if (score >= 90) Console.WriteLine("A"); else if (score >= 70) Console.WriteLine("B"); else Console.WriteLine("C"); // switch statement DayOfWeek day = DayOfWeek.Saturday; switch (day) { case DayOfWeek.Saturday: case DayOfWeek.Sunday: Console.WriteLine("Weekend"); break; default: Console.WriteLine("Weekday"); break; } // switch expression: produces a value string label = score switch { >= 90 => "Excellent", >= 70 => "Good", _ => "Keep going" }; Console.WriteLine(label); // for: you need the index for (int i = 0; i < 3; i++) Console.Write($"{i} "); Console.WriteLine(); // foreach: you just need each item foreach (string fruit in new[] { "apple", "banana" }) Console.Write($"{fruit} "); Console.WriteLine(); // while: repeat until a condition changes int n = 1; while (n < 100) n *= 3; Console.WriteLine(n); // break and continue for (int i = 1; i <= 10; i++) { if (i % 2 == 0) continue; // skip evens if (i > 7) break; // stop entirely Console.Write($"{i} "); } Console.WriteLine(); 

What is happening here:

  • Use for when you need the index, foreach when you just need each item, and while when you do not know in advance how many times to repeat.
  • A switch expression (score switch { >= 90 => ... }) produces a value, so it can be assigned or returned directly. The arms are checked top to bottom, so order matters. _ is the catch-all.
  • In a classic switch statement, every case must end in break (or return). Stacked cases such as Saturday: followed by Sunday: share one body.
  • foreach cannot change the collection it is walking. Adding or removing items inside it throws an InvalidOperationException.

Practice question 3: number-guessing game

Build a number-guessing game with a limited number of attempts and hints. The hint text must come from a switch expression. Invalid input must not crash the game.

Solution

Related posts