Basics & Running
Hello, World
Modern C# uses top-level statements: no
class Program, no static void Main. The cells here show that body; the runner supplies the implicit usings and (when needed) the async entry point.void main() {
print('Hello, World!');
} Console.WriteLine("Hello, World!"); Since C# 9, a file of top-level statements is the program — the compiler generates the
Main for you — so this single line runs. Output goes through Console.WriteLine (from the System namespace, imported implicitly). Statements end in semicolons and the language is otherwise very close to Dart's C-family surface.var, explicit types & interpolation
void main() {
final answer = 42;
double ratio = 3.5;
var greeting = 'hello';
print('$answer $ratio $greeting');
} var answer = 42; // inferred int
double ratio = 3.5; // explicit type before the name (C-style)
var greeting = "hello";
Console.WriteLine($"{answer} {ratio} {greeting}"); C# infers with
var (Dart's var/final both map here — use const/readonly for immutability). Type annotations sit before the name, C-style (double ratio), not after a colon. String interpolation is $"{expr}" — a $ prefix and braces, close to Dart's ${…} but with no bare-$name form.Numbers & typed literals
void main() {
int whole = 7 ~/ 2; // integer division operator
double exact = 7 / 2; // / always yields a double
print('$whole $exact');
} int whole = 7 / 2; // int / int truncates -> 3
double exact = 7.0 / 2; // force a double for 3.5
long big = 5_000_000_000L;
decimal money = 19.99m; // decimal: base-10, for currency
Console.WriteLine($"{whole} {exact} {big} {money}"); Like most C-family languages, C# has no
~/ — / on two ints already truncates, and you force floating point with a double operand. There is no num supertype, but C# offers a richer numeric menu than Dart: sized integers (int/long/byte), double/float, and a base-10 decimal for money that avoids binary rounding error.Nullable Reference Types
Nullable types: ?, ?. and ??
String? findName(int id) => id == 1 ? 'Ada' : null;
void main() {
final name = findName(2);
print(name?.toUpperCase() ?? 'anonymous'); // ?. and ??
} static string? FindName(int id) => id == 1 ? "Ada" : null;
var name = FindName(2);
Console.WriteLine(name?.ToUpper() ?? "anonymous"); // ?. and ?? as in Dart
string? nickname = null;
nickname ??= "assigned"; // ??= too
Console.WriteLine(nickname); The operators port over almost unchanged:
? marks a nullable type, and ?., ??, and ??= behave exactly as in Dart. Under nullable reference types (on by default in new projects) the compiler flow-analyzes nullability much as Dart does — so the surface feels like home, until you learn how much softer the guarantee is (next).Nullability is unsound
This is the trap for a Dart programmer. C#'s null checks are warnings, not errors — they can be ignored, the whole feature can be switched off, and nothing is enforced at run time.
void main() {
String? maybe = null;
// print(maybe.length); // won't COMPILE: maybe may be null
print(maybe?.length ?? 0);
} string? maybe = null;
// Console.WriteLine(maybe.Length); // only a WARNING (CS8602), still compiles
string forced = maybe!; // ! = "trust me, not null" — UNCHECKED
// forced.Length here would throw NullReferenceException at run time
Console.WriteLine(maybe?.Length ?? 0); // the safe way Dart's null safety is sound: dereferencing a nullable is a compile error, full stop. C#'s is unsound — the analysis emits warnings you can suppress, older or opted-out code produces genuine
nulls, and ! (the null-forgiving operator) silences the checker with no runtime guard, so a wrong ! yields a NullReferenceException. Treat the safety as advisory, not guaranteed.Value Types: struct
struct copies; class references
C# has value types, which Dart lacks entirely. A
struct is copied on assignment; a class is a reference like every Dart object.class Point {
int x, y;
Point(this.x, this.y);
}
void main() {
final first = Point(1, 2);
final second = first; // same object — a reference
second.x = 99;
print(first.x); // 99 — aliasing
} var first = new Point(1, 2);
var second = first; // an independent COPY (struct = value type)
second.X = 99;
Console.WriteLine(first.X); // 1 — the copy did not touch first
struct Point(int x, int y) // primary constructor
{
public int X = x;
public int Y = y;
} Declaring
Point a struct makes it a value type, so second = first copies and the two are independent — the model Dart has no equivalent for. Had Point been a class, assignment would alias one object exactly as in Dart. The community default is class; reach for struct for small, immutable, value-like data, and prefer readonly struct to make the value semantics airtight.Records & Value Equality
record: value equality (not a tuple!)
A false friend. Dart's record is an anonymous tuple; C#'s record is a named class with generated value equality — closer to Kotlin's data class or Dart's hand-written
==.class Money {
final int cents;
const Money(this.cents);
@override
bool operator ==(Object other) => other is Money && other.cents == cents;
@override
int get hashCode => cents.hashCode;
@override
String toString() => 'Money($cents)';
}
void main() {
print(Money(500) == Money(500)); // true
} var a = new Money(500);
var b = new Money(500);
Console.WriteLine(a == b); // true — value equality is generated
Console.WriteLine(a); // Money { Cents = 500 } — ToString generated
record Money(int Cents); // one line replaces the whole Dart class A C#
record auto-generates value Equals/GetHashCode, a readable ToString, and deconstruction — collapsing the entire Dart boilerplate on the left into one line. Do not confuse it with Dart's record type (int, String), which is a positional tuple; C#'s tuple is (int, string) instead. A record is a reference type by default (record struct makes it a value type).Non-destructive with
class Point {
final int x, y;
const Point(this.x, this.y);
Point copyWith({int? x, int? y}) => Point(x ?? this.x, y ?? this.y);
@override
String toString() => 'Point($x, $y)';
}
void main() {
final origin = Point(0, 0);
print(origin.copyWith(y: 9)); // Point(0, 9)
} var origin = new Point(0, 0);
var moved = origin with { Y = 9 }; // copy, changing only Y
Console.WriteLine(moved); // Point { X = 0, Y = 9 }
Console.WriteLine(origin); // unchanged
record Point(int X, int Y); Records come with the
with expression — origin with { Y = 9 } — which produces a copy with some properties changed, exactly the job of the hand-written copyWith every Dart value class needs (and the reason freezed exists on the Dart side). It is non-destructive: the original is untouched, so records plus with give you ergonomic immutable updates for free.Classes & Properties
Getters/setters → properties
class Person {
String name;
int _age;
Person(this.name, this._age);
int get age => _age;
set age(int value) => _age = value < 0 ? 0 : value;
}
void main() {
final person = Person('Ada', 36);
person.age = -5;
print('${person.name} ${person.age}'); // Ada 0
} var person = new Person("Ada", 36);
person.Age = -5;
Console.WriteLine($"{person.Name} {person.Age}"); // Ada 0
class Person(string name, int age) // primary constructor
{
public string Name { get; set; } = name; // auto-property
private int _age = age;
public int Age
{
get => _age;
set => _age = value < 0 ? 0 : value; // 'value' is the incoming value
}
} C# properties are the counterpart of Dart's getters/setters, but far more prominent in style. An auto-property
{ get; set; } declares backing storage in one line, while a full property uses get/set bodies with the implicit value parameter. init accessors (set only during construction) and primary constructors (C# 12) trim the ceremony further, so a class body stays terse.Inheritance & override
abstract class Animal {
String speak();
String describe() => 'a ${speak()}er';
}
class Dog extends Animal {
@override
String speak() => 'Woof';
}
void main() => print(Dog().describe()); Console.WriteLine(new Dog().Describe());
abstract class Animal
{
public abstract string Speak();
public string Describe() => $"a {Speak()}er";
}
class Dog : Animal
{
public override string Speak() => "Woof"; // 'override' is REQUIRED
} Single inheritance uses
: (class Dog : Animal), and abstract works as in Dart. The differences are keyword-level: a method must be marked virtual (or abstract) in the base to be overridable, and the subclass must write override explicitly — where Dart's @override is only an optional annotation and any method can be overridden. C# also separates class from interface, rather than every class defining an implicit interface.Operator overloading
class Vector {
final int x, y;
const Vector(this.x, this.y);
Vector operator +(Vector other) => Vector(x + other.x, y + other.y);
@override
String toString() => '($x, $y)';
}
void main() {
print(Vector(1, 2) + Vector(3, 4));
} Console.WriteLine(new Vector(1, 2) + new Vector(3, 4)); // (4, 6)
record Vector(int X, int Y)
{
public static Vector operator +(Vector a, Vector b) // STATIC operator
=> new(a.X + b.X, a.Y + b.Y);
public override string ToString() => $"({X}, {Y})";
} Both languages let a type overload
+, but C# operators are static methods taking both operands (operator +(Vector a, Vector b)), where Dart's operator + is an instance method taking the right-hand side. Value equality via == comes free from the record here; on a plain class you would override Equals/GetHashCode (and, if you want ==, that operator too).Interfaces (no Mixins)
Interfaces & default methods
mixin Greetable {
String get who;
String greet() => 'Hello, $who'; // mixin default method
}
class Person with Greetable {
@override
final String who;
Person(this.who);
}
void main() => print(Person('Ada').greet()); IGreetable person = new Person("Ada"); // an INTERFACE reference
Console.WriteLine(person.Greet()); // default methods need the interface type
interface IGreetable
{
string Who { get; }
string Greet() => $"Hello, {Who}"; // default interface method
}
class Person(string who) : IGreetable
{
public string Who { get; } = who;
} C# has no mixins. Shared behavior comes from an
interface (conventionally prefixed I) that a class opts into with :, and since C# 8 an interface method may carry a default implementation — the nearest analog to a Dart mixin's concrete methods. One catch a Dart programmer hits: a default method is only callable through the interface type (IGreetable person = …), not the concrete class, because it is not inherited onto the class the way a Dart mixin method is. Composition covers the rest, and conformance is explicit here, unlike Dart's implicit interfaces.Cascades → object initializers
class Server {
String host = '';
int port = 0;
bool tls = false;
}
void main() {
final server = Server()
..host = 'db.example'
..port = 5432
..tls = true; // cascade
print('${server.host}:${server.port}');
} var server = new Server // object initializer: set fields inline
{
Host = "db.example",
Port = 5432,
Tls = true,
};
Console.WriteLine($"{server.Host}:{server.Port}");
class Server
{
public string Host = "";
public int Port;
public bool Tls;
} C# has no cascade operator, but its object initializer syntax covers the most common use — setting several properties right after construction — inside
{ … } braces at the new site. It is less general than Dart's .. (which chains arbitrary method calls, not just assignments); for a fluent method chain you would design each method to return this. Collection initializers work the same way for lists and dictionaries.Collections
List → List<T>
void main() {
final numbers = <int>[1, 2, 3];
numbers.add(4);
print(numbers.length);
print(numbers.first);
} List<int> numbers = [1, 2, 3]; // collection expression (C# 12)
numbers.Add(4);
Console.WriteLine(numbers.Count); // .Count, not .length
Console.WriteLine(numbers[0]);
Console.WriteLine(string.Join(", ", numbers)); A Dart
List is a C# List<T>, and since C# 12 you can build one with a collection expression [1, 2, 3] that looks just like Dart's literal. The renames to remember: it is .Count (not .length) and .Add (capitalized, like every public member). C# also has fixed-size arrays (int[]) as a distinct lower-level type, where Dart uses List for both.Map → Dictionary
void main() {
final ages = {'Ada': 36, 'Grace': 45};
print(ages['Ada']);
print(ages.containsKey('Nobody'));
ages.forEach((name, age) => print('$name: $age'));
} var ages = new Dictionary<string, int> { ["Ada"] = 36, ["Grace"] = 45 };
Console.WriteLine(ages["Ada"]); // 36 — throws if key absent!
Console.WriteLine(ages.ContainsKey("Nobody"));
if (ages.TryGetValue("Ada", out var age)) // the safe lookup
Console.WriteLine(age);
foreach (var (name, a) in ages)
Console.WriteLine($"{name}: {a}"); A C#
Dictionary<K, V> is Dart's Map with one sharp difference: indexing a missing key throws a KeyNotFoundException rather than returning null as Dart does. The idiomatic safe lookup is TryGetValue(key, out var value), which returns a bool and binds the value via an out parameter — a small pattern with no Dart equivalent worth getting comfortable with.LINQ
Iterable methods → LINQ
void main() {
final numbers = [1, 2, 3, 4, 5, 6];
final result = numbers
.where((n) => n.isEven)
.map((n) => n * n)
.fold(0, (sum, n) => sum + n);
print(result); // 56
} int[] numbers = [1, 2, 3, 4, 5, 6];
var result = numbers
.Where(n => n % 2 == 0) // where -> Where
.Select(n => n * n) // map -> Select
.Sum(); // fold -> Sum / Aggregate
Console.WriteLine(result); // 56 LINQ is C#'s counterpart to Dart's
Iterable methods, and the chain maps directly: where is Where, map is Select, fold is Aggregate (with dedicated Sum/Max/Count shortcuts). Like Dart's lazy iterables, LINQ is deferred — nothing runs until you enumerate or call a terminal like ToList()/Sum(). It also comes in a SQL-like query syntax (from n in numbers where … select …) with no Dart analog.Grouping & aggregation
void main() {
final words = ['ant', 'bee', 'cat', 'ape', 'bug'];
final byFirst = <String, List<String>>{};
for (final word in words) {
byFirst.putIfAbsent(word[0], () => []).add(word);
}
print(byFirst['a']); // [ant, ape]
} string[] words = ["ant", "bee", "cat", "ape", "bug"];
var byFirst = words
.GroupBy(w => w[0]) // group by first letter
.ToDictionary(g => g.Key, g => g.ToList());
Console.WriteLine(string.Join(", ", byFirst['a'])); // ant, ape Where Dart's standard library makes you build a grouping by hand with
putIfAbsent, LINQ has GroupBy as a first-class operator, alongside OrderBy, Distinct, Join, SelectMany, and more. This breadth is LINQ's real advantage over Dart's collection API — many transformations that are a manual loop in Dart are a single named operator in C#.Pattern Matching
switch expressions & patterns
String classify(Object value) => switch (value) {
int n when n < 0 => 'negative',
int() => 'number',
String() => 'text',
_ => 'other',
};
void main() {
print(classify(-5));
print(classify('hi'));
} static string Classify(object value) => value switch
{
int n when n < 0 => "negative", // type + guard
int => "number",
string => "text",
_ => "other",
};
Console.WriteLine(Classify(-5));
Console.WriteLine(Classify("hi")); C# and Dart 3 converged on nearly the same
switch expression, down to type patterns (int), when guards, and the _ discard. The subject comes before switch in C# (value switch { … }) and arms use =>. C# adds property patterns ({ Length: > 3 }), relational patterns (> 0), and list patterns ([1, .., 9]) — the same family Dart patterns cover.Property & positional patterns
class Point {
final int x, y;
const Point(this.x, this.y);
}
String describe(Point p) => switch (p) {
Point(x: 0, y: 0) => 'origin',
Point(x: 0) => 'on y-axis',
_ => 'elsewhere',
};
void main() => print(describe(Point(0, 0))); Console.WriteLine(Describe(new Point(0, 0)));
static string Describe(Point p) => p switch
{
{ X: 0, Y: 0 } => "origin", // property pattern
(0, _) => "on y-axis", // positional (needs Deconstruct)
_ => "elsewhere",
};
record Point(int X, int Y); C# matches on an object's shape with property patterns —
{ X: 0, Y: 0 } — the direct counterpart of Dart's object pattern Point(x: 0, y: 0). Positional patterns like (0, _) work when the type has a Deconstruct method (records provide one for free). Combined with the relational and list patterns above, C# pattern matching reaches the same expressiveness as Dart's, from the same C-family starting point.async/await & Task
Future → Task
Future<int> fetchCount() async {
await Future.delayed(Duration(milliseconds: 10));
return 42;
}
void main() async {
final count = await fetchCount();
print(count);
} static async Task<int> FetchCount()
{
await Task.Delay(10);
return 42;
}
var count = await FetchCount(); // top-level await is allowed
Console.WriteLine(count); This is a near-perfect match: a Dart
Future<int> is a C# Task<int>, and async/await read identically. A delay is Task.Delay, and top-level statements permit await directly. One semantic difference under the hood: a C# async method does not start until awaited-ish scheduling kicks in via the returned Task, and C# runs on a real thread pool, so continuations may resume on a different thread — where Dart's single isolate always resumes on the same one.Future.wait → Task.WhenAll
Future<int> load(int id) async => id * 10;
void main() async {
final results = await Future.wait([load(1), load(2), load(3)]);
print(results); // [10, 20, 30]
} static async Task<int> Load(int id) => id * 10;
var tasks = new[] { 1, 2, 3 }.Select(Load);
int[] results = await Task.WhenAll(tasks); // run concurrently, await together
Console.WriteLine(string.Join(", ", results)); // 10, 20, 30 Dart's
Future.wait is C#'s Task.WhenAll — run several tasks concurrently and await them as one, preserving order. C# offers the neighbors too: Task.WhenAny (Dart's Future.any), CancellationToken for cooperative cancellation, and IAsyncEnumerable<T> with await foreach for streaming — the equivalent of Dart's Stream.Delegates, Events & Extensions
Function types → Func/Action
void main() {
int Function(int) square = (n) => n * n;
print(square(5));
void Function(String) log = (message) => print('[log] $message');
log('hi');
} Func<int, int> square = n => n * n; // returns a value
Console.WriteLine(square(5));
Action<string> log = message => Console.WriteLine($"[log] {message}"); // void
log("hi"); C# expresses function values through built-in delegate types:
Func<…, TResult> for functions that return a value and Action<…> for those that return void — where Dart writes the type inline as int Function(int). Lambdas (n => n * n) fill them, and you can also declare a named delegate type. It is more machinery than Dart's uniform function types, but the same first-class-function idea.Events (no Dart analog)
void main() {
// Dart has no built-in event/observer keyword; you use a Stream, a
// callback list, or a package like ChangeNotifier for this pattern.
final listeners = <void Function(String)>[];
listeners.add((msg) => print('heard: $msg'));
for (final listener in listeners) listener('tick');
} var clock = new Clock();
clock.Tick += message => Console.WriteLine($"heard: {message}"); // subscribe
clock.Fire();
class Clock
{
public event Action<string>? Tick; // an event
public void Fire() => Tick?.Invoke("tick"); // raise it
} C# has a first-class event — a member built on delegates that callers subscribe to with
+= and unsubscribe with -=, and that only the declaring type can raise. Dart has no such keyword; the same publish/subscribe pattern is built from a Stream, a list of callbacks, or Flutter's ChangeNotifier/Listenable. Events are woven deep into .NET UI and framework APIs, so they are worth recognizing.Extension methods (both have them)
extension NumberWords on int {
String get spelledOut => this == 1 ? 'one' : 'many';
}
void main() {
print(3.spelledOut); // 'many'
} Console.WriteLine(3.SpelledOut()); // "many"
static class NumberExtensions
{
public static string SpelledOut(this int value) // 'this' marks the receiver
=> value == 1 ? "one" : "many";
} Both languages can graft methods onto a type they do not own. C#'s extension methods are
static methods in a static class whose first parameter is marked this, then called with member syntax (3.SpelledOut()). It is a bit more boilerplate than Dart's extension … on int { } block, and — importantly — LINQ itself is built entirely from extension methods on IEnumerable<T>.Generics
Generics & constraints
T largest<T extends Comparable>(List<T> items) {
var winner = items.first;
for (final item in items) {
if (item.compareTo(winner) > 0) winner = item;
}
return winner;
}
void main() {
print(largest<int>([3, 9, 2]));
} static T Largest<T>(IList<T> items) where T : IComparable<T>
{
var winner = items[0];
foreach (var item in items)
if (item.CompareTo(winner) > 0) winner = item;
return winner;
}
Console.WriteLine(Largest([3, 9, 2])); Generics are close, with two differences of note. C# spells the bound with a trailing
where T : IComparable<T> clause (you can stack several: where T : class, new()), rather than Dart's inline extends. And C# generics are reified like Dart's — the type argument survives to run time, so typeof(T) and is T work — with the bonus of declaration-site variance (out T/in T) that Dart does not express.Exceptions & using
try/catch/finally
class InsufficientFunds implements Exception {
final int shortfall;
InsufficientFunds(this.shortfall);
@override
String toString() => 'Short by $shortfall';
}
void main() {
try {
throw InsufficientFunds(150);
} on InsufficientFunds catch (error) {
print(error);
} finally {
print('done');
}
} try
{
throw new InsufficientFunds(150);
}
catch (InsufficientFunds error) // catch by type
{
Console.WriteLine(error.Message);
}
finally
{
Console.WriteLine("done");
}
class InsufficientFunds(int shortfall) : Exception($"Short by {shortfall}")
{
public int Shortfall { get; } = shortfall;
} Exception handling is almost identical:
try/catch/finally and throw line up, and catch (SpecificException e) is Dart's on Type catch. A custom exception inherits from Exception (passing the message to base via the primary constructor here), where Dart implements the Exception interface. C# also adds an exception filter — catch (Exception e) when (e.Message.Length > 0) — which Dart lacks.Deterministic cleanup: using
void main() {
// Dart pairs acquisition and release by hand with try/finally.
final buffer = StringBuffer();
try {
buffer.write('data');
print(buffer.toString());
} finally {
buffer.clear(); // manual cleanup
}
} using (var writer = new StringWriter()) // disposed at end of block
{
writer.Write("data");
Console.WriteLine(writer.ToString());
} // writer.Dispose() runs here automatically
// or the declaration form, disposed at end of scope:
using var other = new StringWriter();
other.Write("more");
Console.WriteLine(other.ToString()); C# has
using, which guarantees an IDisposable's Dispose runs when the block (or enclosing scope) ends — deterministic cleanup for files, sockets, and database connections without a manual finally. It is the pattern Dart handles case-by-case with try/finally (there is no language-level using). Under the hood it is exactly a compiler-generated try/finally calling Dispose.Namespaces & NuGet
Libraries → namespaces
// geometry.dart
// double areaOfCircle(double r) => 3.14159 * r * r;
//
// main.dart
import 'geometry.dart';
void main() {
print(areaOfCircle(2));
} // Types are grouped into NAMESPACES, imported with 'using':
// namespace Geometry { public static class Shapes {
// public static double AreaOfCircle(double r) => 3.14159 * r * r; } }
//
// then: using static Geometry.Shapes;
Console.WriteLine(Circle.Area(2));
static class Circle
{
public static double Area(double r) => 3.14159 * r * r;
} C# organizes code by namespace (a logical grouping, independent of files) and imports with
using Namespace; — bringing in a whole namespace of types rather than Dart's per-file import of a path. Visibility is keyword-based (public/internal/private) instead of Dart's leading-underscore convention, and using static imports a type's static members directly. The Dart side references a second file, so its column is display-only.pub → NuGet
// pubspec.yaml
// dependencies:
// http: ^1.2.0
//
// Then: dart pub get
// Then: import 'package:http/http.dart' as http;
void main() {
print('Dependencies are declared in pubspec.yaml and fetched by pub.');
} // MyApp.csproj
// <ItemGroup>
// <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
// </ItemGroup>
//
// Then: dotnet restore (or: dotnet add package Newtonsoft.Json)
Console.WriteLine("Dependencies live in the .csproj and are fetched by NuGet."); Dart's
pubspec.yaml plus dart pub get corresponds to a .csproj project file with <PackageReference> entries, restored by the dotnet CLI from NuGet (dotnet add package, dotnet restore). Packages come from nuget.org, and the SDK-style .csproj is XML rather than YAML but plays the same role as the manifest.