PONYλM2Modula-2

Swift.CodeCompared.To/C#

An interactive executable cheatsheet comparing Swift and C#

Swift 6.3 C# 14 (.NET 10)
Basics & Syntax
Hello, World
One line each. C# gained top-level statements in version 9, so the class and Main a Swift developer has heard about are no longer required.
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
var means what it means in Swift — inferred and then fixed — but let has no direct counterpart, and the two candidates differ.
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.
Interpolation and Formatting
The $ prefix is Swift's backslash: $"{count}" is "\(count)". Both languages also have raw multi-line strings with three quotes, and both strip the indentation to match the closing delimiter.
import Foundation let name = "widget" let count = 7 print("\(count) x \(name)") print(String(format: "%.2f", 12.5)) print(""" multi line """)
var name = "widget"; var count = 7; Console.WriteLine($"{count} x {name}"); Console.WriteLine($"{12.5:F2}"); Console.WriteLine(""" multi line """);
What C# adds is a format specification after a colon — {value:F2}, {count:N0}, {date:yyyy-MM-dd} — which replaces the String(format:) call Swift makes you drop to. What it lacks is Swift's ExpressibleByStringInterpolation, so you cannot teach the interpolation how to handle your own type; the value is simply ToString()d. Interpolated strings are also compiled into an efficient builder rather than concatenation, so they are the fast path as well as the readable one.
Argument Labels Go Away
Swift argument labels are part of the method's name and the caller must supply them. C# named arguments are optional decoration the caller may add for readability.
// Swift's labels are part of the method name, and the caller // must write them unless the parameter is declared with _. func move(from origin: Int, to destination: Int) -> String { "moved \(origin) to \(destination)" } print(move(from: 1, to: 9))
// C# has named arguments, and they are OPTIONAL — the caller may // use them or not, and they are not part of the method's identity. string Move(int from, int to) => $"moved {from} to {to}"; Console.WriteLine(Move(1, 9)); Console.WriteLine(Move(from: 1, to: 9)); Console.WriteLine(Move(to: 9, from: 1));
The practical consequence is that C# cannot overload on labels the way Swift does — move(from:to:) and move(from:by:) are different methods in Swift, and in C# they would be one signature. What C# gains is that reordering is allowed (Move(to: 9, from: 1) compiles), and that a long parameter list can be clarified at the call site without the API author having planned for it. Optional parameters with defaults cover most of what Swift does with several overloads.
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.)
Nullable Reference Types Are Warnings
🚨 The most important difference on the page. string? looks like String? and the enforcement behind it is a warning, not a type error.
func findName(_ names: [String], startingWith prefix: String) -> String? { names.first { $0.hasPrefix(prefix) } } let found = findName(["ada", "bob"], startingWith: "z") // The compiler REFUSES to let this be used as a String. print(found?.count ?? -1)
string? FindName(List<string> names, string prefix) => names.FirstOrDefault(name => name.StartsWith(prefix)); var found = FindName(new List<string> { "ada", "bob" }, "z"); // This is a WARNING, not an error — and it compiles and throws // unless the project turned warnings into errors. Console.WriteLine(found?.Length ?? -1);
Swift's optionals are sound: a non-optional cannot hold nil, full stop. C# added nullable reference types to a language with twenty years of existing code, so the compiler tracks nullability and can only warn — and the annotation is erased at run time, so nothing checks it there either. Three things follow. Turn on <Nullable>enable</Nullable> and <TreatWarningsAsErrors>, or the feature is documentation. The null-forgiving operator ! is as! with no run-time check at all. And a library compiled without the feature reports everything as oblivious, so its nulls arrive unannounced.
guard Becomes an Early Return
There is no guard. An early return does the same job, and the binding that guard let gives you becomes the compiler's flow analysis narrowing the variable afterwards.
func describe(_ text: String?) -> String { // guard binds for the REST of the scope, and the compiler // enforces that the else branch leaves. guard let value = text, !value.isEmpty else { return "nothing" } return "got \(value.count) characters" } print(describe(nil)) print(describe("hello"))
string Describe(string? text) { // No guard. An early return plus pattern matching is the idiom, // and the narrowing that follows is the compiler's flow analysis // rather than a binding you declared. if (text is not { Length: > 0 }) { return "nothing"; } return $"got {text.Length} characters"; } Console.WriteLine(Describe(null)); Console.WriteLine(Describe("hello"));
text is not { Length: > 0 } is a property pattern, and it is the closest C# gets to guard let value = text, !value.isEmpty in one expression. What is missing is the compiler requiring the else branch to exit — a C# if that falls through simply continues, so the shape is a convention rather than a rule. ArgumentNullException.ThrowIfNull(text) is the idiomatic one-liner when the answer is to throw.
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
A record gives you what a Swift struct gives you free: value equality, a memberwise initializer, and a readable description without writing any of them.
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.
No Copy-on-Write, So Collections Are References
🚨 Swift's Array, Dictionary and Set are value types with copy-on-write. C#'s List<T> and Dictionary<K,V> are classes, so assignment shares them.
// Array, Dictionary and Set are VALUE types with copy-on-write: // assignment is cheap, and mutation copies only if shared. var first = [1, 2, 3] var second = first second.append(4) print(first.count, second.count) // 3 4 — genuinely separate
// List<T> is a CLASS, so assignment shares it. This is the single // most common surprise for a Swift developer writing C#. var first = new List<int> { 1, 2, 3 }; var second = first; second.Add(4); Console.WriteLine($"{first.Count} {second.Count}"); // 4 4 — one list // The explicit copy: var copied = new List<int>(first); copied.Add(5); Console.WriteLine($"{first.Count} {copied.Count}");
This catches every Swift developer at least once, because the value-type intuition transfers perfectly to struct and not at all to collections. The defenses are the ordinary ones: copy at the boundary (new List<int>(other)), hand out IReadOnlyList<T> so a caller cannot modify what you own, or use the genuinely immutable System.Collections.Immutable types. What you get in exchange is that no copy ever happens behind your back — C#'s cost is visible where Swift's is amortized and occasionally surprising.
mutating Becomes readonly, Backwards
The default is inverted. Swift makes a struct method declare mutating; C# lets any struct method mutate and lets you opt out with readonly.
struct Counter { private(set) var value = 0 // A method that changes a struct must SAY SO. mutating func increment() { value += 1 } } var counter = Counter() counter.increment() print(counter.value)
var counter = new Counter(); counter.Increment(); Console.WriteLine(counter.Value); Console.WriteLine(counter.Doubled()); struct Counter { public int Value { get; private set; } // The default is the opposite: a struct method may mutate, and // you opt OUT with readonly. public void Increment() => Value += 1; public readonly int Doubled() => Value * 2; }
🚨 That inversion has a performance consequence with no Swift equivalent: calling a non-readonly method on a readonly field or an in parameter makes the compiler emit a defensive copy of the whole struct, silently, on every call. Marking every non-mutating struct method readonly is therefore not pedantry — it is how you avoid copies you never asked for. The compiler will tell you where with warning CS8656 if you enable it.
Properties & Initializers
Properties, observers, and init-only setters
Swift stores properties and computes them with braces; C# does the same with { get; set; }, and the auto-implemented form hides a backing field you never name.
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.
Computed Properties, and No Observers
Computed properties correspond almost exactly — => is Swift's implicit-return braces. Property observers do not exist, and become a hand-written setter.
struct Rectangle { var width: Int var height: Int var area: Int { width * height } // computed var label: String = "" { didSet { print("label changed to \(label)") } // observer } } var rectangle = Rectangle(width: 3, height: 4) print(rectangle.area) rectangle.label = "wide"
var rectangle = new Rectangle { Width = 3, Height = 4 }; Console.WriteLine(rectangle.Area); rectangle.Label = "wide"; struct Rectangle { // Required as soon as a field has an initializer (CS8983). public Rectangle() { } public int Width { get; init; } public int Height { get; init; } public int Area => Width * Height; // computed, same idea // There is no didSet/willSet. An observer is a full property // with a backing field, written out. private string label = ""; public string Label { get => label; set { label = value; Console.WriteLine($"label changed to {value}"); } } }
Two C# additions worth adopting. { get; init; } makes a property settable only during object initialization, which is closer to a Swift let on a struct than readonly is. And the object-initializer syntax — new Rectangle { Width = 3 } — is how C# replaces Swift's memberwise initializer, since it does not generate one. What you lose along with didSet is willSet, lazy var, and property wrappers, which have no counterpart at all.
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
C# switch expressions look close to Swift's, and the patterns available are narrower — the row below shows how far they reach.
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.
🚨 No Associated Values
🚨 This is the largest single loss for a Swift developer. A C# enum is a named integer — it cannot carry associated values, so the whole algebraic-data-type style has to be rebuilt.
enum Shape { case circle(radius: Double) case rect(width: Double, height: Double) } func area(_ shape: Shape) -> Double { switch shape { case .circle(let radius): return 3.14159 * radius * radius case .rect(let width, let height): return width * height } // Exhaustive: adding a case breaks the build here. } print(area(.rect(width: 2, height: 3)))
double Area(Shape shape) => shape switch { Circle circle => 3.14159 * circle.Radius * circle.Radius, Rect rect => rect.Width * rect.Height, _ => throw new ArgumentException("unhandled shape") }; Console.WriteLine($"{Area(new Rect(2, 3)):F1}"); // A C# enum is a named integer and cannot carry a payload. The // replacement is a type hierarchy plus pattern matching. abstract record Shape; record Circle(double Radius) : Shape; record Rect(double Width, double Height) : Shape;
Records plus a switch expression get you close, and the gap that remains is exhaustiveness: the _ => throw arm exists because C# cannot prove the list is complete, so adding a third shape produces a run-time exception rather than a build error. Sealing the hierarchy helps a little — mark the base abstract and the cases in one file — and C# still will not check it. That discard arm is the idiom precisely because it turns a silent wrong answer into a loud failure.
Protocols & Interfaces
Protocols become interfaces
An interface is a protocol, and the correspondence is unusually direct: both support default implementations and both can be used as a constraint.
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
An extension method is a static method the compiler rewrites your call into — so it looks like a Swift extension and cannot do the same things.
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.
No Retroactive Conformance
Swift lets you conform a type you did not write to a protocol you did not write. C# extension methods look similar and cannot do this: they add a callable method, not a conformance.
protocol Describable { func describe() -> String } // A type you did not write, conformed retroactively. extension Int: Describable { func describe() -> String { "the number \(self)" } } func announce(_ value: Describable) { print(value.describe()) } announce(42)
Console.WriteLine(42.Describe()); // Announce(IDescribable) could not accept 42 at all. // A C# extension method cannot make a type implement an interface. // int does not become IDescribable, so this only works as a static // call — the interface never enters into it. interface IDescribable { string Describe(); } static class IntExtensions { public static string Describe(this int value) => $"the number {value}"; }
This is the deepest difference between protocols and interfaces, and it changes design. A Swift library can say "anything Codable" and a user can retrofit an existing type; a C# library must either be handed an interface the type already implements, or take a delegate, or use generic constraints with a helper. .NET's usual answers are a delegate parameter (Func<T, string>), an adapter class, or — since .NET 7 — static abstract interface members, which are how INumber<T> finally made generic math possible.
Collections & LINQ
map and filter become LINQ — and it is lazy
map, filter and reduce become Select, Where and Aggregate, and the chain reads the same way.
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.
LINQ Is Lazy, and Swift Is Not
The methods correspond and the evaluation does not: Swift's map and filter build arrays immediately, while LINQ's Select and Where build a query that has not run yet.
let values = [1, 2, 3, 4, 5, 6] // map and filter each build a NEW ARRAY, eagerly. let doubled = values.filter { $0 % 2 == 0 }.map { $0 * $0 } print(doubled) // .lazy opts into deferring, and is rarely used. let lazily = values.lazy.filter { $0 % 2 == 0 }.map { $0 * $0 } print(Array(lazily))
var values = new List<int> { 1, 2, 3, 4, 5, 6 }; // Where and Select build a QUERY, not a list. Nothing runs until // something consumes it — here, ToList. var doubled = values.Where(value => value % 2 == 0) .Select(value => value * value); Console.WriteLine(string.Join(", ", doubled.ToList())); // 🚨 And enumerating it twice runs the whole chain twice. Console.WriteLine(doubled.Count());
🚨 Two consequences a Swift developer will meet. Enumerating a query twice executes it twice — including any database round trip — which is why ToList() at the end of a chain is so common in real code. And a closure capturing a variable that later changes will see the new value, because the chain runs at consumption time rather than at definition. In exchange, chaining ten operations makes one pass rather than nine intermediate arrays, and IQueryable lets the same syntax be translated into SQL — which Swift has no counterpart for.
The Method Names, Side by Side
The vocabulary maps almost one to one, with C# capitalizing everything and preferring an explicit key selector where Swift uses a closure returning Bool.
let values = [3, 1, 4, 1, 5] print(values.sorted()) print(values.first { $0 > 3 } ?? -1) print(values.contains(4)) print(values.allSatisfy { $0 > 0 }) print(values.reduce(0, +)) print(Array(Set(values)).sorted())
var values = new List<int> { 3, 1, 4, 1, 5 }; Console.WriteLine(string.Join(", ", values.OrderBy(v => v))); Console.WriteLine(values.FirstOrDefault(v => v > 3, -1)); Console.WriteLine(values.Contains(4)); Console.WriteLine(values.All(v => v > 0)); Console.WriteLine(values.Sum()); Console.WriteLine(string.Join(", ", values.Distinct().OrderBy(v => v)));
The pairs worth memorizing: sorted() is OrderBy, first(where:) is FirstOrDefault, allSatisfy is All, contains(where:) is Any, compactMap is Where(x => x is not null).Select(…), flatMap is SelectMany, and Set deduplication is Distinct. 🚨 FirstOrDefault without the default argument returns default(T) — which is 0 for an int, not null — so an empty result and a legitimate zero are indistinguishable unless you pass one.
Errors & Exceptions
throws vanishes from the signature
C# has no throws in the signature and no try at the call site. Any method may throw anything, and nothing is declared.
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?.
No try, and No Result
C# has no throws clause, no try at the call site, and no try?. The Try pattern — a bool return plus an out parameter — is what replaces the optional form.
enum ParseError: Error { case notANumber(String) } func parse(_ text: String) throws -> Int { guard let value = Int(text) else { throw ParseError.notANumber(text) } return value } // try? turns a throw into an optional; try! traps. print((try? parse("42")) ?? -1) print((try? parse("oops")) ?? -1)
int Parse(string text) => int.TryParse(text, out var value) ? value : throw new ParseException($"not a number: {text}"); // There is no try? — the equivalent is the Try pattern, which // returns a bool and writes the answer to an out parameter. Console.WriteLine(int.TryParse("42", out var first) ? first : -1); Console.WriteLine(int.TryParse("oops", out var second) ? second : -1); class ParseException : Exception { public ParseException(string message) : base(message) { } }
The Try pattern is everywhere in .NET precisely because throwing for an expected failure is expensive, and its limitation is that a bool can say something went wrong but not what. There is no Result type in the standard library either, so a method that needs to explain a failure throws. The one thing C# gives you that Swift does not is an exception filter: catch (Exception error) when (error.Message.Contains("timeout")) tests a condition without unwinding first, which has no Swift equivalent.
defer Becomes using
defer registers cleanup at the call site; using ties it to a type that implements IDisposable. Both run on every path out, including an exception.
func work() { print("opened") defer { print("closed") } print("working") } work() print("after work()")
void Work() { using var held = new Held(); Console.WriteLine("working"); } Work(); Console.WriteLine("after work()"); class Held : IDisposable { public Held() => Console.WriteLine("opened"); public void Dispose() => Console.WriteLine("closed"); }
The difference is where the obligation lives. Swift's defer can clean up anything — a flag, a counter, a lock you took by hand — while using needs a disposable type, so an arbitrary "run this on the way out" becomes try/finally. Going the other way, using puts the cleanup in the type so a caller cannot forget it, which defer cannot do. await using is the asynchronous form, and has no Swift counterpart.
ARC vs Garbage Collection
No ARC, no deinit, no defer
Both languages free memory for you and they do it by different mechanisms — reference counting in Swift, tracing in C# — which changes when destruction happens.
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 leakweak 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.
No Retain Cycles to Break
A reference cycle leaks under ARC and is ordinary under a tracing collector, so weak and unowned have no C# counterpart and nothing needs breaking.
class Node { var next: Node? weak var previous: Node? // weak, or this leaks deinit { print("node freed") } } do { let first = Node() let second = Node() first.next = second second.previous = first } print("scope ended")
{ var first = new Node(); var second = new Node(); first.Next = second; second.Previous = first; } // A cycle is collectible: the collector traces from roots rather // than counting references. Console.WriteLine("scope ended"); class Node { public Node? Next; public Node? Previous; // no weak needed ~Node() { } // a finalizer, and you should not write one }
That removes a whole category of Swift design work — no capture lists, no [weak self], no delegate-must-be-weak rule. What you give up is deinit: a C# finalizer runs at an unpredictable time on a separate thread, may never run at all, and makes an object take two collections to die. 🚨 Do not write one. Anything with a lifetime gets IDisposable and a using, which is the previous row. C# does have WeakReference<T>, and it is for caches rather than for breaking cycles.
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.
No Actors, and No Sendable
Swift 5.5 made data-race safety a compiler concern: an actor serializes access to its state, and Sendable checks what may cross a boundary. C# has neither.
// An actor serializes access to its own state, and the compiler // checks that nothing reaches it unsafely. actor Counter { private var value = 0 func increment() -> Int { value += 1 return value } } let counter = Counter() print("actors are a language feature")
var counter = new Counter(); Console.WriteLine(counter.Increment()); // C# has no actor and no Sendable. Shared state is protected by a // lock you remember to take, and nothing checks that you did. class Counter { private int value; private readonly object guard = new(); public int Increment() { lock (guard) { return ++value; } } }
This is the clearest place Swift is ahead, and the gap widened with Swift 6's strict concurrency checking. C#'s equivalents are all conventions and libraries: lock, the System.Collections.Concurrent types, Interlocked, and SemaphoreSlim for the asynchronous case — since 🚨 you must never lock across an await, which the compiler will not stop you doing. A data race in C# gives you a wrong value; in Swift 6 it gives you a compile error.
Task Groups and Cancellation
Task.WhenAll is withTaskGroup's nearest equivalent, and the structural guarantee behind it is different: a C# Task may outlive the method that created it.
// Structured concurrency: the group cannot outlive the scope, and // cancellation propagates to every child automatically. // // try await withThrowingTaskGroup(of: String.self) { group in // group.addTask { try await work("first") } // group.addTask { try await work("second") } // for try await result in group { print(result) } // } // // Task.checkCancellation() is how a child cooperates. print("structured: children cannot outlive the scope")
// C# tasks are UNstructured: a Task may outlive the method that // started it, and cancellation is a token you pass by hand. // // using var source = new CancellationTokenSource(); // var first = Work("first", source.Token); // var second = Work("second", source.Token); // await Task.WhenAll(first, second); // // Every async API in .NET takes a CancellationToken by convention, // and passing it down is your job at every level. Console.WriteLine("unstructured: a Task can outlive its caller");
Structured concurrency means a Swift child task cannot escape its scope and inherits cancellation automatically. In C# both are your responsibility: a forgotten await produces a fire-and-forget task whose exception is never observed, and cancellation only reaches a child if you threaded the token to it. The compensation is that CancellationToken is a framework-wide convention every async .NET API accepts, and CancellationTokenSource composes with timeouts and linked tokens more flexibly than Swift's cooperative model does.

Thank you — anything else?