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,charandstringare the everyday types. Usedecimalfor money, becausedoublestores binary fractions and cannot represent 0.1 exactly (the0.1 + 0.2 == 0.3line prints False). -
7 / 2is3, not3.5, because both sides areint. Cast one side todoublefirst. -
(int)dis an explicit cast: it truncates, it does not round. -
TryParsereturns true or false and hands the result back through anoutvariable. 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:
- 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.
- One validation rule covers every scale. Checking
kelvin < 0after converting means you never write separate minimum values (-273.15, -459.67, 0) for each scale. -
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. -
TryParsewithCultureInfo.InvariantCulturemeans36.6works on every machine, even ones whose regional settings use a comma as the decimal separator. Bad input takes thecontinuepath instead of an exception. - An
enumfor the scale means the compiler catches a typo likeScale.Kelvn, which a string comparison never would. The switch expressions throw in the_arm so a future new enum member cannot be silently ignored. -
TryParseScaleis its own method with anoutparameter, the same shape asint.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 leavessalone. 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. Avoida.ToLower() == b.ToLower(), which allocates two throwaway strings. - Use
StringBuilderwhen 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:
- 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. - One
StringBuilderis reused for every word.Clear()empties it without allocating a new one, so there is no string concatenation inside the loop. -
counts[w] = counts.GetValueOrDefault(w) + 1is the standard counting idiom: a word not seen before counts as 0, then becomes 1. NoContainsKeycheck, noKeyNotFoundException. - The final
AddWordcall 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. - 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.
- 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
forwhen you need the index,foreachwhen you just need each item, andwhilewhen 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(orreturn). Stacked cases such asSaturday:followed bySunday:share one body. -
foreachcannot change the collection it is walking. Adding or removing items inside it throws anInvalidOperationException.
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
const int Min = 1; const int Max = 100; constSource:https://dev.to/manoharij/c-basics-day-1-types-strings-control-flow-methods-and-collections-explained-with-five-practice-48ck