Basics & Syntax
Hello, World
print("Hello, World!") Console.WriteLine("Hello, World!"); Modern C# has top-level statements, so a program no longer needs a class and a
Main method — this is the whole file, exactly as in Swift. Semicolons are mandatory, and the implicit using directives (System, System.Linq, System.Collections.Generic, …) are added for you, which is why Console and List<T> just work.let and var become var and const-ish
let fixed = 1
var counter = 0
counter += 1
let explicit: Int64 = 42
let name = "Ada"
print("\(name): \(fixed + counter), \(explicit)")
let letter = """
Dear \(name),
Regards
"""
print(letter) // 'var' is INFERENCE, not mutability — it is Swift's 'var' and 'let' both.
var counter = 0;
counter += 1;
// The immutable local is 'const' (compile-time values only) — there is no
// general-purpose 'let', and nothing stops a var being reassigned.
const int Fixed = 1;
long explicitValue = 42;
var name = "Ada";
// String interpolation with a $ prefix.
Console.WriteLine($"{name}: {Fixed + counter}, {explicitValue}");
// A raw string literal (C# 11): the closing quotes set the indent, as in Swift.
var letter = $"""
Dear {name},
Regards
""";
Console.WriteLine(letter); The false friend arrives immediately: C#'s
var means inferred, not mutable, so it covers both of Swift's keywords and carries none of the meaning. Immutability for a local is const (compile-time constants only) or readonly on a field — there is no general let, and the immutable-by-default discipline you have in Swift becomes a convention you keep by hand. Interpolation is $"{expression}", and C# 11 raw string literals strip the indentation from the closing delimiter, exactly like Swift's multi-line strings.Optionals & Nullable Types
string? is not String?
The most important concept on this page. C# has the same operators you know — and the guarantee behind them is much weaker.
func findName(_ id: Int) -> String? {
id == 1 ? "Ada" : nil
}
// The compiler REFUSES the unsafe version. This is a type error, not a warning.
print(findName(1)?.uppercased() ?? "(none)")
if let name = findName(1) {
print(name.count)
}
func shout(_ id: Int) -> String {
guard let name = findName(id) else { return "(nobody)" }
return name.uppercased()
}
print(shout(99))
// String and String? are DIFFERENT TYPES, at run time as well as compile time. string? FindName(int id) => id == 1 ? "Ada" : null;
// The operators are all here: ?. is ?., ?? is ??, and ! is the force unwrap.
Console.WriteLine(FindName(1)?.ToUpper() ?? "(none)");
Console.WriteLine(FindName(99)?.ToUpper() ?? "(none)");
// Pattern matching is the 'if let': 'is not null' narrows the type.
if (FindName(1) is string name)
{
Console.WriteLine(name.Length);
}
string Shout(int id)
{
// The guard-let equivalent: an early return on null.
if (FindName(id) is not string found) return "(nobody)";
return found.ToUpper();
}
Console.WriteLine(Shout(99));
// BUT: nullable reference types are a COMPILER ANALYSIS, not a type. string and
// string? are the SAME type at run time — the annotation is erased. Dereferencing
// a null is a warning you can ignore, disable, or lie your way past with !
string definitelyNotNull = FindName(99)!; // the ! silences the compiler...
try
{
Console.WriteLine(definitelyNotNull.Length); // ...and this throws at run time
}
catch (NullReferenceException)
{
Console.WriteLine("NullReferenceException — the annotation did not protect us");
} The syntax will lull you.
?., ??, ??= and ! all mean what you expect, and is string name is a decent if let. But nullable reference types are a static analysis, not a type: string and string? compile to the same runtime type, the checking produces warnings rather than errors (and is off entirely in older projects), and every string can still hold null if it came from a library that was not annotated, from reflection, or from deserialization. Swift's String? is a genuinely different type that the runtime knows about. The practical advice: turn on <Nullable>enable</Nullable> and TreatWarningsAsErrors in your project file, and treat ! the way you treat try!. (Nullable value types — int? — are real: they are Nullable<int>, a struct, and they do exist at run time.)Structs, Classes & Records
struct is a value type here too
The reason C# feels closer to Swift than any other target: it kept the value/reference split, and it means what you think it means.
struct Point {
var x: Int
var y: Int
}
class Counter {
var count = 0
}
var first = Point(x: 1, y: 2)
var second = first // a COPY
second.x = 99
print(first.x, second.x) // 1 99
let counterA = Counter()
let counterB = counterA // a REFERENCE
counterB.count = 5
print(counterA.count) // 5 var first = new Point { X = 1, Y = 2 };
var second = first; // a COPY — struct is a value type
second.X = 99;
Console.WriteLine($"{first.X} {second.X}"); // 1 99
var counterA = new Counter();
var counterB = counterA; // a REFERENCE — class is a reference type
counterB.Count = 5;
Console.WriteLine(counterA.Count); // 5
// The trap Swift does not have: a struct in a LIST is copied out of it, so this
// modifies a copy and the list is unchanged.
var points = new List<Point> { new Point { X = 0, Y = 0 } };
var copied = points[0];
copied.X = 42;
Console.WriteLine(points[0].X); // still 0
// Which is why value types in C# are conventionally IMMUTABLE (readonly struct).
struct Point
{
public int X;
public int Y;
}
class Counter
{
public int Count;
} A
struct is a value type and a class is a reference type, exactly as in Swift, and the assignment semantics follow. Two differences worth carrying. C# structs have no copy-on-write: a copy is a real, eager copy of the bytes, so a large struct is genuinely expensive to pass around (Swift's Array is a struct precisely because COW makes that cheap; a C# List<T> is a class). And indexing a collection of structs hands you a copy, so mutating it changes nothing — the source of a classic C# head-scratcher, and the reason the community convention is readonly struct with immutable fields.Records: value equality and with-expressions
struct Person: Equatable {
let name: String
let age: Int
}
let ada = Person(name: "Ada", age: 36)
print(ada)
print(ada == Person(name: "Ada", age: 36)) // structural equality, free
// "Copy with a change" — the whole point of a value type.
var older = ada
// older.age = 37 ← let, so: build a new one
let updated = Person(name: ada.name, age: 37)
print(updated) // A record gives you value equality, a nice ToString, deconstruction, and
// 'with' — on a CLASS (record) or a STRUCT (record struct).
var ada = new Person("Ada", 36);
Console.WriteLine(ada); // Person { Name = Ada, Age = 36 }
Console.WriteLine(ada == new Person("Ada", 36)); // TRUE — records compare by value,
// even though this one is a class
// 'with' is the copy-with-changes expression. This is Swift's struct update,
// and it is the single nicest thing C# has that Swift does not.
var older = ada with { Age = 37 };
Console.WriteLine(older);
Console.WriteLine(ada); // unchanged
// Deconstruction, from the positional parameters.
var (name, age) = ada;
Console.WriteLine($"{name} {age}");
public record Person(string Name, int Age); A
record is C#'s data type: value equality, a readable ToString, deconstruction, and the with expression — and note the last one, because Swift has no equivalent. ada with { Age = 37 } produces a modified copy in one expression, which is exactly what you want from an immutable value and what Swift makes you write a full initializer call for. A plain record is a class (a reference type) that merely compares by value; record struct is the value type. For a Swift developer, record with all-init-only properties is the closest thing to the struct you would have written.Properties & Initializers
Properties, observers, and init-only setters
class Rectangle {
let width: Double
var height: Double
var area: Double { width * height } // computed
var label: String = "" {
didSet { print("label is now \(label)") } // an observer
}
lazy var expensive: String = {
print("computing")
return "cached"
}()
init(width: Double, height: Double) {
self.width = width
self.height = height
}
}
let rectangle = Rectangle(width: 3, height: 4)
print(rectangle.area)
rectangle.height = 10
print(rectangle.area)
rectangle.label = "big"
print(rectangle.expensive)
print(rectangle.expensive) var rectangle = new Rectangle { Width = 3, Height = 4 };
Console.WriteLine(rectangle.Area);
rectangle.Height = 10;
Console.WriteLine(rectangle.Area);
rectangle.Label = "big";
Console.WriteLine(rectangle.Expensive);
Console.WriteLine(rectangle.Expensive);
public class Rectangle
{
// 'init' is a setter usable ONLY in the object initializer — Swift's 'let'
// property, in effect: set once at construction, immutable thereafter.
public double Width { get; init; }
public double Height { get; set; }
// A computed property. Same idea, '=>' for the body.
public double Area => Width * Height;
// No property observers. You write the full property with a backing field.
private string label = "";
public string Label
{
get => label;
set
{
label = value; // 'value' is the implicit parameter
Console.WriteLine($"label is now {value}");
}
}
// No 'lazy var' keyword: Lazy<T> is the library type that does it.
private readonly Lazy<string> expensive = new(() =>
{
Console.WriteLine("computing");
return "cached";
});
public string Expensive => expensive.Value;
} Properties are a first-class feature in both languages, and the translation is mostly mechanical:
var area: Double { … } becomes public double Area => …, and the object-initializer syntax (new Rectangle { Width = 3 }) plus init-only setters gives you something very close to a Swift memberwise initializer with let properties. Two things get more verbose: there are no property observers (didSet/willSet become a hand-written property with a backing field, where value is the implicit setter parameter), and there is no lazy var keyword — Lazy<T> is a library type that does the same job with more ceremony.Enums & Pattern Matching
The enum is a named integer
The biggest expressiveness gap. A C#
enum cannot carry associated values — it is an integer with names, and it is not even a closed set.enum Status: String, CaseIterable {
case active = "running"
case paused = "on hold"
}
for status in Status.allCases {
print(status, status.rawValue)
}
print(Status(rawValue: "running") ?? .paused)
// And the real feature: associated values.
enum Shape {
case circle(radius: Double)
case rectangle(width: Double, height: Double)
}
func area(_ shape: Shape) -> Double {
switch shape { // exhaustive
case .circle(let radius): return 3.14159 * radius * radius
case .rectangle(let width, let height): return width * height
}
}
print(area(.circle(radius: 1))) // A C# enum is an int with names. No payloads, no raw strings, and NOT closed:
foreach (Status status in Enum.GetValues<Status>())
{
Console.WriteLine($"{status} {(int)status}");
}
Console.WriteLine(Enum.Parse<Status>("Active"));
Console.WriteLine((Status)99); // prints 99 — a value that is not a case!
// Sum types with payloads: a sealed record hierarchy plus pattern matching.
double Area(Shape shape) => shape switch
{
Circle circle => 3.14159 * circle.Radius * circle.Radius,
Rectangle rectangle => rectangle.Width * rectangle.Height,
_ => 0 // the compiler WARNS if it can prove non-exhaustiveness, but the
}; // discard arm is conventional — this is not Swift's guarantee
Console.WriteLine(Area(new Circle(1)));
Console.WriteLine(Area(new Rectangle(2, 3)));
public enum Status { Active, Paused }
public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape; Two losses. A C#
enum is a thin wrapper over an integer: no associated values, no string raw values (an attribute or a dictionary does that job), and — startlingly — not a closed set, since (Status)99 is a legal Status. And a sum type with payloads becomes an abstract record hierarchy plus a switch expression, which reads well (the pattern matching is genuinely good) but does not give you Swift's exhaustiveness: the compiler can warn when it proves a case is unhandled, and idiomatic code still writes the _ discard arm, so adding a third shape usually falls through silently.switch expressions and patterns
func describe(_ value: Any) -> String {
switch value {
case let number as Int where number > 100:
return "big number \(number)"
case let number as Int:
return "number \(number)"
case let text as String where text.isEmpty:
return "empty string"
case let text as String:
return "string of \(text.count)"
case nil:
return "nothing"
default:
return "something else"
}
}
print(describe(7))
print(describe(1000))
print(describe("swift"))
// Tuple matching, with ranges.
let point = (1, -1)
switch point {
case (0, 0): print("origin")
case (let x, 0): print("on x axis at \(x)")
case (let x, let y) where x > 0 && y < 0: print("quadrant 4")
default: print("elsewhere")
} string Describe(object? value) => value switch
{
int number when number > 100 => $"big number {number}", // 'when' is 'where'
int number => $"number {number}",
string { Length: 0 } => "empty string", // a PROPERTY pattern
string text => $"string of {text.Length}",
null => "nothing",
_ => "something else",
};
Console.WriteLine(Describe(7));
Console.WriteLine(Describe(1000));
Console.WriteLine(Describe("csharp"));
Console.WriteLine(Describe(null));
// Tuple patterns, relational patterns, and combinators (and/or/not).
var point = (X: 1, Y: -1);
var quadrant = point switch
{
(0, 0) => "origin",
(var x, 0) => $"on x axis at {x}",
( > 0, < 0) => "quadrant 4", // relational patterns, with no variable at all
_ => "elsewhere",
};
Console.WriteLine(quadrant);
// List patterns (C# 11), which Swift does not have.
int[] numbers = [1, 2, 3, 4];
Console.WriteLine(numbers switch
{
[] => "empty",
[var single] => $"one: {single}",
[var first, .., var last] => $"{first}...{last}",
}); C#'s pattern matching has quietly overtaken Swift's. Everything you know is here — type patterns,
when guards (Swift's where), tuple patterns — plus three things Swift lacks: property patterns (string { Length: 0 }, matching on a member without binding the whole value), relational patterns (> 0, < 0, combinable with and/or/not), and list patterns ([var first, .., var last]). The switch expression (with => arms and a trailing comma) is the modern form, and it is what you will write.Protocols & Interfaces
Protocols become interfaces
protocol Describable {
var name: String { get }
func describe() -> String
}
// A protocol extension supplies the default.
extension Describable {
func describe() -> String { "I am \(name)" }
}
struct Dog: Describable {
let name: String
}
struct Robot: Describable {
let name: String
func describe() -> String { "BEEP. I AM \(name.uppercased())" }
}
let things: [any Describable] = [Dog(name: "Rex"), Robot(name: "R2")]
for thing in things {
print(thing.describe())
} List<IDescribable> things = [new Dog("Rex"), new Robot("R2")];
foreach (var thing in things)
{
Console.WriteLine(thing.Describe());
}
public interface IDescribable
{
string Name { get; }
// A DEFAULT INTERFACE METHOD (C# 8) — Swift's protocol extension.
string Describe() => $"I am {Name}";
}
public record Dog(string Name) : IDescribable;
public record Robot(string Name) : IDescribable
{
public string Describe() => $"BEEP. I AM {Name.ToUpper()}";
} Interfaces with default methods are protocol extensions, and interface properties (
string Name { get; }) cover protocol properties. Two differences a Swift developer will notice. The I prefix is a hard convention (IDescribable, IEnumerable) and everyone follows it. And C# has no retroactive conformance: you cannot make an existing type implement an interface from outside, which is the trick Swift extensions do all the time — the C# workaround is an adapter class or an extension method, and neither is as good.Extension methods live in a static class
extension String {
var initials: String {
split(separator: " ").compactMap { $0.first }.map(String.init).joined()
}
func shout() -> String { uppercased() + "!" }
}
print("Ada Lovelace".initials)
print("hello".shout()) Console.WriteLine("Ada Lovelace".Initials());
Console.WriteLine("hello".Shout());
// The extension must live in a STATIC class, and the receiver is the first
// parameter, marked 'this'. There are no extension PROPERTIES (yet) — only
// methods — so 'initials' becomes Initials().
public static class StringExtensions
{
public static string Initials(this string text) =>
string.Concat(text.Split(' ').Select(word => word[0]));
public static string Shout(this string text) => text.ToUpper() + "!";
} Same feature, more ceremony: an extension method is a static method in a static class whose first parameter is marked
this, and it is dispatched statically (as in Swift). The gap is extension properties — C# has none, so text.Initials must become text.Initials(). This is also the mechanism behind LINQ: Where, Select and the rest are extension methods on IEnumerable<T>, which is why they appear on every collection in the language.Collections & LINQ
map and filter become LINQ — and it is lazy
let names = ["Ada", "Grace", "Alan", "Barbara"]
// Eager: each step allocates a new Array.
print(names.filter { $0.count > 3 }.map { $0.uppercased() })
print(names.map(\.count).reduce(0, +))
print(names.sorted())
print(names.first { $0.hasPrefix("G") } ?? "(none)")
print(Dictionary(grouping: names, by: \.count).keys.sorted())
var ages = ["Ada": 36]
ages["Alan"] = 41
print(ages["Ada"] ?? 0) // an Optional
print(ages["Nobody"] ?? 0) string[] names = ["Ada", "Grace", "Alan", "Barbara"];
// LINQ is LAZY: nothing runs until ToList()/First()/Sum() forces it.
Console.WriteLine(string.Join(", ",
names.Where(name => name.Length > 3).Select(name => name.ToUpper())));
Console.WriteLine(names.Sum(name => name.Length));
Console.WriteLine(string.Join(", ", names.OrderBy(name => name)));
Console.WriteLine(names.FirstOrDefault(name => name.StartsWith("G")) ?? "(none)");
Console.WriteLine(string.Join(", ",
names.GroupBy(name => name.Length).Select(group => group.Key).Order()));
var ages = new Dictionary<string, int> { ["Ada"] = 36 };
ages["Alan"] = 41;
Console.WriteLine(ages["Ada"]);
// Indexing a missing key THROWS — it does not return null.
Console.WriteLine(ages.GetValueOrDefault("Nobody", 0));
Console.WriteLine(ages.TryGetValue("Nobody", out var age) ? age : 0); The vocabulary shifts (
filter is Where, map is Select, reduce is Aggregate or a specialized Sum), and the evaluation model flips: LINQ is lazy, so a chain builds a query and nothing executes until a terminal operation (ToList, First, Sum, a foreach) pulls on it. That is Swift's .lazy, on by default. Two traps: iterating the same query twice re-runs it (call .ToList() to materialize), and indexing a Dictionary with a missing key throws rather than returning an optional — TryGetValue or GetValueOrDefault is the safe form.Errors & Exceptions
throws vanishes from the signature
enum ParseError: Error {
case notANumber(String)
case notPositive(Int)
}
// 'throws' is in the signature, and 'try' is required at every call site.
func parsePositive(_ text: String) throws -> Int {
guard let number = Int(text) else { throw ParseError.notANumber(text) }
guard number > 0 else { throw ParseError.notPositive(number) }
return number
}
do {
print(try parsePositive("42"))
print(try parsePositive("-1"))
} catch ParseError.notPositive(let number) {
print("not positive: \(number)")
} catch {
print("failed: \(error)")
}
print((try? parsePositive("nope")) ?? -1) // No 'throws' and no 'try' at the call site: exceptions are unchecked and
// invisible in the signature. Nothing tells a caller this can fail.
int ParsePositive(string text)
{
if (!int.TryParse(text, out var number)) throw new NotANumberException(text);
if (number <= 0) throw new NotPositiveException(number);
return number;
}
try
{
Console.WriteLine(ParsePositive("42"));
Console.WriteLine(ParsePositive("-1"));
}
catch (NotPositiveException error)
{
Console.WriteLine($"not positive: {error.Number}");
}
catch (Exception error) when (error is NotANumberException) // an exception FILTER
{
Console.WriteLine($"failed: {error.Message}");
}
finally
{
Console.WriteLine("done");
}
// The Try-pattern is the idiomatic way to avoid exceptions for expected failure —
// it is C#'s 'try?', and it is why int.TryParse exists.
Console.WriteLine(int.TryParse("nope", out var parsed) ? parsed : -1);
public class NotANumberException(string text) : Exception($"not a number: {text}");
public class NotPositiveException(int number) : Exception($"not positive: {number}")
{
public int Number { get; } = number;
} All exceptions are unchecked and none of them appear in a signature, so the information Swift puts in the type system (
throws) simply is not there — you learn what can fail from the documentation or from production. In exchange the happy path is uncluttered by try. Two idioms are worth adopting: exception filters (catch (Exception e) when (…)), which let you inspect before catching rather than catching and rethrowing; and the Try-pattern (int.TryParse(text, out var number)), which returns a bool instead of throwing and is the standard-library answer for failure you expect — the closest thing to try?.ARC vs Garbage Collection
No ARC, no deinit, no defer
class Node {
let name: String
weak var parent: Node? // without weak, this cycle LEAKS
var children: [Node] = []
init(name: String) { self.name = name }
deinit { print("freeing \(name)") } // deterministic, the instant the last ref dies
}
func build() {
let parent = Node(name: "parent")
let child = Node(name: "child")
parent.children.append(child)
child.parent = parent
}
build() // both deinits run HERE
print("done")
// defer, for scoped cleanup.
func process() {
print("open")
defer { print("close") }
print("work")
}
process() Build();
Console.WriteLine("done");
// 'using' is the cleanup construct, and it is tied to a RESOURCE, not a scope:
// Dispose() runs at the end of the block, on every exit path.
using (var resource = new Resource())
{
resource.Work();
}
// Or the declaration form: disposed at the end of the enclosing scope.
using var another = new Resource();
another.Work();
void Build()
{
var parent = new Node("parent");
var child = new Node("child");
parent.Children.Add(child);
child.Parent = parent; // a cycle — and the tracing GC collects it anyway.
} // nothing happens here. The GC gets to it on its own schedule.
public class Node(string name)
{
public string Name { get; } = name;
public Node? Parent { get; set; } // no 'weak' needed, and none exists
public List<Node> Children { get; } = [];
// There IS a finalizer (~Node), and you must not use it: it runs on the GC
// thread, at an unpredictable time, or never.
}
public class Resource : IDisposable
{
public Resource() => Console.WriteLine("open");
public void Work() => Console.WriteLine("work");
public void Dispose() => Console.WriteLine("close");
} The .NET runtime uses a tracing garbage collector, so three Swift habits go away. Cycles are not a leak —
weak and unowned have no counterpart and no purpose (WeakReference<T> exists, but for caches, not for breaking cycles). There is no deinit: finalizers exist, run nondeterministically, and are actively discouraged. And there is no defer — cleanup attaches to a resource that implements IDisposable, and using calls Dispose() on every exit path. The trade is the same one every GC language makes: you stop thinking about retain cycles and start thinking about which objects hold unmanaged resources.Async & Concurrency
async/await — the original
C# invented this syntax in 2012, and Swift adopted it nine years later. The keywords are the same; the ownership model is not.
import Foundation
func fetch(_ id: Int) async -> String {
try? await Task.sleep(for: .milliseconds(10))
return "user-\(id)"
}
// Structured concurrency: the group OWNS its children, waits for them, and
// cancels them together if one fails.
func fetchAll() async -> [String] {
await withTaskGroup(of: String.self) { group in
for id in 1...3 {
group.addTask { await fetch(id) }
}
var results: [String] = []
for await result in group {
results.append(result)
}
return results.sorted()
}
}
print(await fetch(1))
print(await fetchAll()) async Task<string> Fetch(int id)
{
await Task.Delay(10);
return $"user-{id}";
}
Console.WriteLine(await Fetch(1));
// Calling the async method STARTS it — a Task is hot, and nothing owns it.
var tasks = Enumerable.Range(1, 3).Select(Fetch);
var results = await Task.WhenAll(tasks); // awaitAll
Array.Sort(results);
Console.WriteLine(string.Join(", ", results));
// The race: WhenAny is 'select' over tasks.
var first = await Task.WhenAny(Fetch(1), Fetch(2));
Console.WriteLine($"first: {await first}");
// Cancellation is a TOKEN you pass by hand — there is no task tree that
// propagates it for you.
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(5));
try
{
await Task.Delay(100, cancellation.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("canceled, via the token we threaded through");
} The syntax is identical and the semantics differ in ways that matter. A
Task is hot: calling an async method starts the work immediately, so there is no lazy async { } and no scope that owns it — a forgotten task leaks rather than being canceled with its parent. There is no structured concurrency: Task.WhenAll waits, but nothing forms a tree, and cancellation is a CancellationToken you thread through every call by hand where Swift propagates it down the task tree for free. On the other side of the ledger, .NET has real parallelism with no GIL, and Task.WhenAny gives you a race that Swift needs a task group to express. There are no actors — lock, Interlocked, or a Channel<T> do that job, and none of them is checked by the compiler.