PONY λ M2 Modula-2

Dart.CodeCompared.To/TypeScript

An interactive executable cheatsheet comparing Dart and TypeScript

Dart 3.7 TypeScript 6.0
Basics & Running
Hello, World
void main() { print('Hello, World!'); }
console.log("Hello, World!");
TypeScript is JavaScript with a compile-time type layer, so a script is just top-level statements — there is no main() entry point to declare. Everything on this page compiles to plain JavaScript with the types erased, which is the single fact that drives most of the surprises ahead.
final/var becomes const/let
void main() { final answer = 42; var counter = 0; counter += 1; print(answer + counter); }
const answer = 42; let counter = 0; counter += 1; console.log(answer + counter);
Dart's final maps to const and var to let, with the same caveat you already know: const freezes the binding, not the object, so a const array still mutates. TypeScript's const is not Dart's compile-time const — there is no canonicalization and no deep immutability here; that job falls to readonly and as const later.
Inference & annotations
void main() { final answer = 42; final double ratio = 3.5; final greeting = 'hello'; print('$answer $ratio $greeting'); }
const answer = 42; const ratio: number = 3.5; const greeting = "hello"; console.log(answer, ratio, greeting);
Annotations sit after a colon exactly as in Dart, and inference covers most bindings. The jarring part is that ratio and answer share one type: TypeScript has a single number (an IEEE-754 double), so there is no int/double distinction and no num supertype to reach for.
String interpolation
void main() { final name = 'Ada'; final year = 1843; print('$name wrote the first program in $year.'); print('Next year: ${year + 1}'); }
const name = "Ada"; const year = 1843; console.log(`${name} wrote the first program in ${year}.`); console.log(`Next year: ${year + 1}`);
The idea is identical but the punctuation shifts: TypeScript uses backtick template literals and requires ${…} braces around every interpolation, including a bare variable — there is no $name shorthand like Dart's. Ordinary double-quoted strings do no interpolation at all.
One number type
void main() { int whole = 7 ~/ 2; // integer division double exact = 7 / 2; // always double BigInt huge = BigInt.parse('9007199254740993'); print('$whole $exact $huge'); }
const whole = Math.trunc(7 / 2); // no ~/ operator const exact = 7 / 2; // 3.5 — / is never integer const huge = 9007199254740993n; // bigint, the n suffix console.log(whole, exact, huge);
Dart's int/double/BigInt trio collapses to one number plus a separate bigint. There is no ~/; integer division is Math.trunc(a / b) or Math.floor. Because number is a double, 0.1 + 0.2 !== 0.3 and integers above 2^53 lose precision — the reason bigint exists.
Structural & Erased
Shape is identity
The deepest reversal from Dart: an interface is a shape, not a lineage. A value satisfies it by having the right fields — no implements clause anywhere.
class Point { final int x, y; Point(this.x, this.y); } int manhattan(Point point) => point.x.abs() + point.y.abs(); void main() { print(manhattan(Point(3, -4))); }
interface Point { x: number; y: number; } function manhattan(point: Point): number { return Math.abs(point.x) + Math.abs(point.y); } // Never declared as a Point — but it has the shape, so it IS one. console.log(manhattan({ x: 3, y: -4 }));
In Dart a type conforms only if it explicitly implements or extends; here any object with an x and y number is a Point. This structural rule means two unrelated interfaces with the same fields are interchangeable — powerful, and occasionally a bug the nominal Dart type system would have caught.
Generics are erased
void main() { final numbers = <int>[1, 2, 3]; final words = <String>['a', 'b']; print(numbers is List<int>); // true — Dart reifies type arguments print(words is List<String>); // true }
const numbers: number[] = [1, 2, 3]; const words: string[] = ["a", "b"]; // You CANNOT write `numbers instanceof Array<number>` — the <number> // does not exist at run time. Only the erased Array survives. console.log(numbers instanceof Array); // true console.log(words instanceof Array); // true — indistinguishable
Dart reifies generics: List<int> knows it holds ints at run time, and is List<int> works. TypeScript erases every type argument during compilation, so at run time number[] and string[] are the same bare Array. Any runtime check on a type parameter is impossible — a habit worth unlearning early.
dynamic → any; Object? → unknown
void main() { dynamic loose = 'anything goes'; print(loose.length); // no check — may crash at run time Object? safe = 'must be tested first'; if (safe is String) print(safe.length); // promotion after the test }
let loose: any = "anything goes"; console.log(loose.length); // any: no checking, no safety const safe: unknown = "must be tested first"; if (typeof safe === "string") { console.log(safe.length); // unknown forces the narrowing first }
Dart's dynamic is TypeScript's any — both disable checking entirely and are best avoided. The closer analog to Dart's Object? is unknown: it accepts any value but permits nothing until you narrow it, giving you the safe top type. Reach for unknown wherever you would have reached for Object?.
"as" is a compile-time lie
void main() { Object value = 'not a number'; try { final number = value as int; // checked — throws at run time print(number); } catch (error) { print('Dart caught it: $error'); } }
const value: unknown = "not a number"; // `as` is erased — NO runtime check happens. This "succeeds", // then explodes later when the fake number is used as one. const pretend = value as number; try { console.log(pretend.toFixed(2)); // TypeError, far from the cast site } catch (error) { console.log("blew up later:", (error as Error).message); }
This is the most dangerous single difference. Dart's as inserts a runtime type check and throws immediately on a bad cast. TypeScript's as is a compile-time assertion that is erased — it silences the checker and performs no verification, so the failure surfaces later and elsewhere. Treat every as as an unaudited promise you are making to the compiler.
typedef → type alias
typedef Transform = int Function(int value); typedef Json = Map<String, dynamic>; void main() { Transform doubler = (value) => value * 2; print(doubler(21)); }
type Transform = (value: number) => number; type Json = Record<string, unknown>; const doubler: Transform = (value) => value * 2; console.log(doubler(21));
Dart's typedef becomes TypeScript's type alias, and it does far more here: beyond naming function signatures it can name unions, tuples, mapped and conditional types. A function type uses the fat-arrow form (x: T) => U, and Map<String, dynamic> is idiomatically a Record<string, unknown>.
Null Safety, Minus the Soundness
Nullable types
String? findName(int id) => id == 1 ? 'Ada' : null; void main() { final name = findName(1); // print(name.length); // won't compile — name may be null if (name != null) print(name.length); // promotion }
function findName(id: number): string | null { return id === 1 ? "Ada" : null; } const name = findName(1); // console.log(name.length); // Error: name is possibly null if (name !== null) { console.log(name.length); // narrowed to string }
Nullability is opt-in on both sides, but the syntax differs: Dart appends ? to the type, while TypeScript spells it as a union with nullstring | null. Under strictNullChecks the compiler tracks it and refuses a bare access, just like Dart. The refinement inside the if is called narrowing rather than promotion.
TWO kinds of absence
void main() { int? missing; // one and only absence: null print(missing); // null final map = {'a': 1}; print(map['b']); // absent key is also null }
let missing: number | undefined; // uninitialized -> undefined console.log(missing); // undefined, NOT null const record: Record<string, number> = { a: 1 }; console.log(record["b"]); // undefined — absent property const explicitNull: number | null = null; console.log(explicitNull); // null — a distinct thing
Dart has exactly one bottom value, null. TypeScript has two: undefined (an uninitialized binding, a missing property, a function's implicit return) and null (a value someone chose to assign). An optional field x?: T means T | undefined. Most codebases pick one convention and lean on == null, which uniquely matches both.
?., ?? and ??= carry over
void main() { String? nickname; print(nickname?.toUpperCase()); // null, no crash print(nickname ?? 'anonymous'); // default via ?? nickname ??= 'assigned'; // assign only if null print(nickname); }
let nickname: string | undefined; console.log(nickname?.toUpperCase()); // undefined, no crash console.log(nickname ?? "anonymous"); // ?? — nullish coalescing nickname ??= "assigned"; // ??= — same as Dart console.log(nickname);
This trio ports over unchanged: ?., ??, and ??= mean exactly what they do in Dart. One subtlety — TypeScript's ?? triggers on null or undefined only, deliberately unlike ||, which also fires on 0, "", and false. Prefer ?? when you mean "is it absent", the way Dart taught you.
! is unchecked here
int? maybe() => null; void main() { try { final value = maybe()!; // ! throws immediately if null print(value); } catch (error) { print('Dart threw on the bang'); } }
function maybe(): number | null { return null; } const value = maybe()!; // ! is erased — no runtime check console.log(value + 1); // silently NaN, no error thrown
Dart's ! is a runtime null-check that throws the instant the value is null. TypeScript's non-null assertion ! is compile-time only and erased: it tells the checker "trust me" and inserts no guard, so a wrong assertion produces a quiet undefined/NaN downstream instead of a clean failure. Like as, it is a promise, not a check.
late has no true analog
class Profile { late final String displayName; // assigned once, later void configure(String name) { displayName = name; } } void main() { final profile = Profile(); profile.configure('Grace'); print(profile.displayName); }
class Profile { displayName!: string; // definite-assignment: "I'll set it, trust me" configure(name: string): void { this.displayName = name; } } const profile = new Profile(); profile.configure("Grace"); console.log(profile.displayName);
Dart's late defers initialization while keeping the field non-nullable, and it throws if you read it before it is set. The nearest TypeScript tool is the definite-assignment assertion ! on a field, but it is weaker — purely a compile-time waiver with no runtime "used before initialized" guard. There is no lazy-late (compute-on-first-read) equivalent; use a getter for that.
Unions replace Sealed Classes
Union types
// Dart has no union type; you widen to a common supertype // and test with `is`, or reach for a sealed class. void main() { Object idOrName = 3; final text = idOrName is int ? 'id #$idOrName' : 'name $idOrName'; print(text); }
// A first-class union: the value is a number OR a string. function describe(idOrName: number | string): string { return typeof idOrName === "number" ? `id #${idOrName}` : `name ${idOrName}`; } console.log(describe(3)); console.log(describe("Ada"));
Dart has no anonymous union, so "an int or a string" forces you up to Object and back down through is checks. TypeScript makes the union a real type — number | string — and typeof narrowing splits it back apart safely. This single feature quietly replaces a lot of Dart's sealed-class boilerplate.
sealed class → discriminated union
sealed class Shape {} class Circle extends Shape { final double r; Circle(this.r); } class Square extends Shape { final double side; Square(this.side); } double area(Shape shape) => switch (shape) { Circle(:final r) => 3.14159 * r * r, Square(:final side) => side * side, }; void main() => print(area(Circle(2)));
type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return 3.14159 * shape.r * shape.r; case "square": return shape.side * shape.side; } } console.log(area({ kind: "circle", r: 2 }));
A Dart sealed hierarchy becomes a discriminated union: each member carries a shared literal tag (here kind), and switching on that tag narrows the object in each branch. It is data, not a class tree — no subclassing, no extends — but it earns the same closed-set exhaustiveness, shown next.
Exhaustiveness with never
Dart's switch over a sealed type is exhaustive automatically. TypeScript needs one idiom — an assertNever whose parameter is never — to make a missing case a compile error.
sealed class Payment {} class Cash extends Payment {} class Card extends Payment {} String label(Payment payment) => switch (payment) { Cash() => 'cash', Card() => 'card', // omit a case and Dart refuses to compile }; void main() => print(label(Card()));
type Payment = { kind: "cash" } | { kind: "card" }; function assertNever(value: never): never { throw new Error(`unhandled: ${JSON.stringify(value)}`); } function label(payment: Payment): string { switch (payment.kind) { case "cash": return "cash"; case "card": return "card"; default: return assertNever(payment); // errors if a case is missed } } console.log(label({ kind: "card" }));
When every case is handled, the value reaching default has type never, so the call type-checks. Add a third union member and forget its case, and payment is no longer never — the call fails to compile. It is the manual, opt-in version of the exhaustiveness Dart gives you for free on a sealed switch.
Enums, and the string-union alternative
enum Direction { north, east, south, west } void main() { final heading = Direction.east; print(heading.name); // 'east' print(Direction.values.length); // 4 }
// A real enum exists... enum Direction { North, East, South, West } console.log(Direction[Direction.East]); // "East" // ...but the idiomatic choice is a string-literal union: type Heading = "north" | "east" | "south" | "west"; const heading: Heading = "east"; console.log(heading);
TypeScript has an enum keyword, but unlike Dart's it emits a runtime object and has some well-known sharp edges. Most modern code prefers a union of string literals: it is erased (zero runtime cost), it narrows like any union, and the values are the readable strings themselves. Dart's enhanced enums with methods have no direct equal — model those as a union plus helper functions.
Records, Tuples & Destructuring
Records → tuples & objects
(int, String) minMaxLabel(List<int> numbers) { numbers.sort(); return (numbers.first, 'sorted'); } void main() { final (smallest, note) = minMaxLabel([5, 2, 9]); print('$smallest / $note'); }
function minMaxLabel(numbers: number[]): [number, string] { const sorted = [...numbers].sort((a, b) => a - b); return [sorted[0], "sorted"]; } const [smallest, note] = minMaxLabel([5, 2, 9]); console.log(`${smallest} / ${note}`);
A positional Dart record (int, String) maps to a TypeScript tuple [number, string] — a fixed-length, position-typed array. Both destructure at the call site. Note the sort difference lurking here: TypeScript's Array.sort is in-place and defaults to string order, so numbers need an explicit (a, b) => a - b comparator.
Named records → object types
({int width, int height}) screen() => (width: 1920, height: 1080); void main() { final size = screen(); print('${size.width} x ${size.height}'); }
function screen(): { width: number; height: number } { return { width: 1920, height: 1080 }; } const size = screen(); console.log(`${size.width} x ${size.height}`);
Dart's named record fields ({int width, int height}) are simply an object literal with a structural type in TypeScript. Because typing is structural, you never declare a name for this shape unless you want to reuse it — the anonymous { width: number; height: number } is a full-fledged type on its own.
Destructuring & spread
void main() { final point = {'x': 3, 'y': 4}; final numbers = [1, 2, 3, 4]; final [first, ...rest] = numbers; // list pattern print('$first $rest'); print('${point['x']}, ${point['y']}'); }
const point = { x: 3, y: 4 }; const numbers = [1, 2, 3, 4]; const [first, ...rest] = numbers; // array destructuring const { x, y } = point; // object destructuring console.log(first, rest); console.log(x, y);
Array destructuring with a rest element mirrors Dart's list patterns closely. Object destructuring, const { x, y } = point, has no Dart counterpart for maps and is used constantly — it is the standard way to pull fields out of an object or unpack a function's options argument (the functions section returns to this).
No pattern matching in switch
void main() { final pair = (2, 3); final result = switch (pair) { (0, _) => 'x is zero', (final a, final b) when a == b => 'equal', (final a, final b) => 'sum ${a + b}', }; print(result); }
const pair: [number, number] = [2, 3]; // No structural patterns or guards — destructure, then use if/else. const [a, b] = pair; let result: string; if (a === 0) result = "x is zero"; else if (a === b) result = "equal"; else result = `sum ${a + b}`; console.log(result);
This is a genuine loss coming from Dart 3. TypeScript's switch compares a single value with === — there is no destructuring pattern, no when guard, and no record/list matching. You destructure first and fall back to an if/else ladder. Discriminated-union switches (above) recover the closed-set half, but not the shape-matching half.
Classes & Objects
Classes & constructor fields
class Point { final int x; final int y; Point(this.x, this.y); // this.x shorthand int get manhattan => x.abs() + y.abs(); } void main() { print(Point(3, -4).manhattan); }
class Point { constructor( public readonly x: number, public readonly y: number, ) {} // parameter properties get manhattan(): number { return Math.abs(this.x) + Math.abs(this.y); } } console.log(new Point(3, -4).manhattan);
TypeScript's parameter properties — an access modifier on a constructor parameter — are the direct echo of Dart's this.x shorthand, declaring and assigning the field in one place. Two differences to internalize: instantiation requires the new keyword, and member access always needs an explicit this. — there is no implicit receiver.
Named & factory constructors → statics
class Temperature { final double celsius; Temperature(this.celsius); Temperature.fahrenheit(double f) : celsius = (f - 32) * 5 / 9; factory Temperature.boiling() => Temperature(100); } void main() { print(Temperature.fahrenheit(212).celsius); print(Temperature.boiling().celsius); }
class Temperature { constructor(public celsius: number) {} static fromFahrenheit(f: number): Temperature { return new Temperature((f - 32) * 5 / 9); } static boiling(): Temperature { return new Temperature(100); } } console.log(Temperature.fromFahrenheit(212).celsius); console.log(Temperature.boiling().celsius);
TypeScript allows only one constructor per class, so Dart's named constructors (Temperature.fahrenheit) and factory constructors both become static methods that return an instance. You lose the constructor-tear-off niceties, but the pattern — named alternative ways to build a value — carries over cleanly as static factories.
No cascade operator
class Basket { final List<String> items = []; void add(String item) => items.add(item); } void main() { final basket = Basket() ..add('apple') ..add('pear') ..add('plum'); // cascade — no repeated receiver print(basket.items); }
class Basket { items: string[] = []; add(item: string): this { // return this to chain this.items.push(item); return this; } } const basket = new Basket() .add("apple") .add("pear") .add("plum"); console.log(basket.items);
Dart's cascade .. lets you fire several calls at one receiver even when each returns void. TypeScript has no cascade; to chain you must design each method to return this (a fluent builder). For a plain configuration block, most code just repeats the variable name or destructures — there is no free chaining over void methods.
No 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)); // uses operator + }
class Vector { constructor(public x: number, public y: number) {} add(other: Vector): Vector { // a plain method — no + overload return new Vector(this.x + other.x, this.y + other.y); } toString(): string { return `(${this.x}, ${this.y})`; } } console.log(new Vector(1, 2).add(new Vector(3, 4)).toString());
Dart lets a class define operator +, operator ==, operator [], and more. TypeScript supports none of this: + on objects only ever does string/number coercion, so arithmetic-like APIs become named methods such as add. Similarly there is no operator == override — value equality is hand-written, covered next.
Equality is reference by default
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; } void main() { print(Money(500) == Money(500)); // true — value equality }
class Money { constructor(public cents: number) {} equals(other: Money): boolean { // define your own — no === hook return this.cents === other.cents; } } console.log(new Money(500) === new Money(500)); // false: by reference console.log(new Money(500).equals(new Money(500))); // true: your method
In Dart you override operator == and hashCode to get value semantics. TypeScript's === is always reference identity for objects and cannot be overridden, so two structurally identical instances are not equal. Value equality is a method you call explicitly (or a library helper) — and there is no hashCode, since Map/Set key on reference or primitive value, never on a custom hash.
Inheritance & interfaces
abstract class Animal { String speak(); } class Dog extends Animal { @override String speak() => 'Woof'; } void main() => print(Dog().speak());
abstract class Animal { abstract speak(): string; } class Dog extends Animal { speak(): string { // no @override annotation exists return "Woof"; } } console.log(new Dog().speak());
Single inheritance with extends and abstract works as in Dart. The differences: TypeScript has no @override annotation (you can enable a compiler flag that requires an override keyword instead), and it separates interface (pure structural shape, implemented with implements) from class — whereas in Dart every class also defines an implicit interface.
Mixins → function mixins
Dart's with mixins have no keyword equivalent. The TypeScript pattern is a function that takes a base class and returns an extended subclass — verbose, but it composes.
mixin Timestamped { DateTime get now => DateTime(2024, 1, 1); String stamp() => 'at $now'; } class Log with Timestamped {} void main() => print(Log().stamp());
type Constructor = new (...args: any[]) => object; function Timestamped<Base extends Constructor>(base: Base) { return class extends base { stamp(): string { return "at 2024-01-01"; } }; } class Log {} const Stamped = Timestamped(Log); console.log(new Stamped().stamp());
Dart mixes behavior in with a single with clause and strict linearization. TypeScript has no mixin keyword; the community pattern is a mixin function — a generic function over a base constructor that returns an anonymous subclass. It works and even composes, but it is boilerplate-heavy and loses Dart's clean linearization guarantees.
Functions & Parameters
Named params → options object
String greet({required String name, String greeting = 'Hello'}) => '$greeting, $name!'; void main() { print(greet(name: 'Ada')); print(greet(name: 'Ada', greeting: 'Hi')); }
function greet({ name, greeting = "Hello" }: { name: string; greeting?: string; }): string { return `${greeting}, ${name}!`; } console.log(greet({ name: "Ada" })); console.log(greet({ name: "Ada", greeting: "Hi" }));
TypeScript has no named parameters. The idiom is a single options object destructured in the parameter list, with defaults written inline and optional fields marked ? in the object type. It reproduces Dart's call-site readability — greet({ name: "Ada" }) — but the "required" guarantee comes from the field being non-optional in the type, not from a required keyword.
Optional & default parameters
int increment(int value, [int by = 1]) => value + by; void main() { print(increment(10)); print(increment(10, 5)); }
function increment(value: number, by: number = 1): number { return value + by; } console.log(increment(10)); console.log(increment(10, 5));
Dart's optional positional parameters use square brackets; TypeScript writes a trailing = default directly, or marks a parameter optional with by?: number. There is no bracket syntax and no distinction between optional-positional and optional-named — a defaulted or ? parameter is simply the last one(s) you may omit.
Arrow bodies & closures
void main() { final square = (int n) => n * n; // expression closure int counter = 0; final tick = () => counter += 1; // captures counter tick(); tick(); print('${square(5)} $counter'); }
const square = (n: number): number => n * n; let counter = 0; const tick = (): number => (counter += 1); // captures counter tick(); tick(); console.log(square(5), counter);
Dart's => expression body and TypeScript's arrow functions look and behave almost identically, closures included. The gotcha for a Dart programmer is this: an arrow function captures the surrounding this lexically (usually what you want), whereas the older function keyword rebinds this per call — a footgun Dart simply does not have.
No varargs; use rest params
// Dart has no variadic parameter — you pass a List. int sumAll(List<int> numbers) => numbers.fold(0, (total, each) => total + each); void main() { print(sumAll([1, 2, 3, 4])); }
function sumAll(...numbers: number[]): number { return numbers.reduce((total, each) => total + each, 0); } console.log(sumAll(1, 2, 3, 4)); // variadic call, no array literal
Here TypeScript is actually the more flexible one: a rest parameter ...numbers: number[] collects any number of trailing arguments, so callers write sumAll(1, 2, 3, 4) with no list literal — something Dart cannot express, since Dart has no variadics and requires an explicit List. Spread ... also expands an existing array back into arguments.
Lists, Maps & Sets
List → Array
void main() { final numbers = <int>[1, 2, 3]; numbers.add(4); final doubled = numbers.map((n) => n * 2).toList(); final evens = numbers.where((n) => n.isEven).toList(); print('$doubled $evens'); }
const numbers: number[] = [1, 2, 3]; numbers.push(4); // add const doubled = numbers.map((n) => n * 2); // no .toList() const evens = numbers.filter((n) => n % 2 === 0); // where -> filter console.log(doubled, evens);
Dart's List<T> is TypeScript's Array<T> (written T[]). The method names shift: add is push, where is filter, and — crucially — map/filter return arrays eagerly, so there is no .toList() to materialize a lazy Iterable. Chains are immediate, not deferred.
Map → Map or object
void main() { final ages = {'Ada': 36, 'Grace': 45}; ages['Alan'] = 41; print(ages['Ada']); ages.forEach((name, age) => print('$name: $age')); }
const ages = new Map<string, number>([ ["Ada", 36], ["Grace", 45], ]); ages.set("Alan", 41); console.log(ages.get("Ada")); ages.forEach((age, name) => console.log(`${name}: ${age}`));
Dart's map literal {k: v} is a Map; TypeScript reserves {…} for objects and offers a distinct Map class with get/set. Prefer Map for real dictionaries (any key type, reliable iteration order, a real size). A plain object works for fixed string keys but keys are always strings, and inherited properties can leak in — a subtle bug a Dart Map never has.
Set → Set
void main() { final seen = <int>{1, 2, 2, 3}; seen.add(3); print(seen.length); // 3 print(seen.contains(2)); // true }
const seen = new Set<number>([1, 2, 2, 3]); seen.add(3); console.log(seen.size); // 3 — .size, not .length console.log(seen.has(2)); // .has, not .contains
Both languages have a first-class Set that de-duplicates on insertion. The renames to remember: it is .size (not .length) and .has (not .contains). One caveat inherited from equality — a TypeScript Set of objects dedupes by reference, so two structurally equal objects are two members, whereas a Dart Set can honor a custom ==.
No collection-if / collection-for
void main() { final showExtra = true; final menu = [ 'home', if (showExtra) 'settings', // collection-if for (final n in [1, 2]) 'item$n', // collection-for ]; print(menu); }
const showExtra = true; const menu = [ "home", ...(showExtra ? ["settings"] : []), // spread a conditional array ...[1, 2].map((n) => `item${n}`), // map, then spread ]; console.log(menu);
This is a Dart idiom you will miss daily. Dart lets you embed if and for directly inside a collection literal — the backbone of every Flutter children: list. TypeScript has only the spread operator, so a conditional element becomes ...(cond ? [x] : []) and a generated run becomes ...items.map(...). It works, but it is noisier.
Immutability: readonly & as const
void main() { const origin = [0, 0]; // compile-time constant, deeply immutable final palette = List<String>.unmodifiable(['red', 'green']); print(origin); print(palette); // palette.add('blue'); // throws: unmodifiable }
const origin = [0, 0] as const; // readonly tuple, frozen type const palette: readonly string[] = ["red", "green"]; console.log(origin); console.log(palette); // palette.push("blue"); // compile error: no push on readonly
Dart offers const collections and List.unmodifiable with real runtime enforcement. TypeScript's readonly T[] and as const are compile-time only — they remove the mutating methods from the type but do not freeze the value, so a cast or an any can still mutate it. For runtime immutability you must call Object.freeze yourself.
fold → reduce
void main() { final numbers = [1, 2, 3, 4]; final total = numbers.fold<int>(0, (sum, each) => sum + each); final maxValue = numbers.reduce((a, b) => a > b ? a : b); print('$total $maxValue'); }
const numbers = [1, 2, 3, 4]; const total = numbers.reduce((sum, each) => sum + each, 0); // fold const maxValue = numbers.reduce((a, b) => (a > b ? a : b)); // no seed console.log(total, maxValue);
Dart splits the reduction in two: fold takes an initial value of any type, while reduce has no seed and stays in the element type. TypeScript folds both into one reduce — pass an initial value for the fold behavior, or omit it for the seedless reduce. Omitting the seed on an empty array throws, exactly as Dart's seedless reduce does.
Iteration & Generators
for-in becomes for-of (watch for-in!)
void main() { final colors = ['red', 'green', 'blue']; for (final color in colors) { // iterates VALUES print(color); } }
const colors = ["red", "green", "blue"]; for (const color of colors) { // for-OF iterates values console.log(color); } // for (const i in colors) — for-IN gives "0","1","2" (indices as strings!)
A dangerous false friend: Dart's for (x in list) iterates values, but TypeScript's for..in iterates keys — array indices as strings, plus any enumerable inherited property. The one that matches your Dart habit is for..of. Reach for for..of to iterate values and avoid for..in on arrays entirely.
Iterable laziness → generators
void main() { // Dart's Iterable chain is lazy; nothing runs until iterated. final firstThree = Iterable<int> .generate(1000000, (i) => i) .map((i) => i * i) .take(3); print(firstThree.toList()); // only 3 squares computed }
function* naturals(): Generator<number> { for (let i = 0; ; i++) yield i; // infinite, but lazy } const firstThree: number[] = []; for (const i of naturals()) { firstThree.push(i * i); if (firstThree.length === 3) break; } console.log(firstThree);
Dart's Iterable is lazy by default, so map/take chains do minimal work. TypeScript arrays are strictly eager — every .map materializes the whole array — so an Array.map over a huge range would compute all of it. To recover laziness you drop down to a generator (function*), which yields on demand exactly like a Dart Iterable.
sync* → function*
Iterable<int> countdown(int from) sync* { for (var n = from; n > 0; n--) { yield n; } } void main() { print(countdown(3).toList()); // [3, 2, 1] }
function* countdown(from: number): Generator<number> { for (let n = from; n > 0; n--) { yield n; } } console.log([...countdown(3)]); // [3, 2, 1]
Dart's sync*/yield generator function maps directly to TypeScript's function*/yield. Collecting the results differs: Dart calls .toList(), while TypeScript spreads the generator into an array with [...gen()]. Dart's yield* (delegate to another iterable) is spelled the same, yield*, in TypeScript.
Async on One Thread
Future → Promise
Future<int> fetchCount() async { await Future.delayed(Duration(milliseconds: 10)); return 42; } void main() async { final count = await fetchCount(); print(count); }
function fetchCount(): Promise<number> { return new Promise((resolve) => setTimeout(() => resolve(42), 10)); } (async () => { const count = await fetchCount(); console.log(count); })();
Dart's Future<T> is TypeScript's Promise<T>, and async/await read identically. Two differences: a Promise has no Future.delayed, so a timed delay is new Promise around setTimeout; and like a Dart Future, a Promise is eager — it starts running the moment it is created, not when awaited.
Future.wait → Promise.all
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] }
function load(id: number): Promise<number> { return Promise.resolve(id * 10); } (async () => { const results = await Promise.all([load(1), load(2), load(3)]); console.log(results); // [10, 20, 30] })();
Dart's Future.wait is Promise.all — run several async operations concurrently and await them together, preserving order. The extras differ in name: Dart's Future.any is Promise.race/Promise.any, and TypeScript adds Promise.allSettled for "wait for all, successes and failures alike", which Dart lacks a single-call equivalent for.
Stream → async generator
Stream<int> ticks(int count) async* { for (var i = 1; i <= count; i++) { yield i; } } void main() async { await for (final tick in ticks(3)) { print(tick); } }
async function* ticks(count: number): AsyncGenerator<number> { for (let i = 1; i <= count; i++) { yield i; } } (async () => { for await (const tick of ticks(3)) { console.log(tick); } })();
Dart's Stream<T> with async*/yield and await for maps neatly to a TypeScript async generator (async function*) consumed with for await..of. What has no built-in equal is Dart's rich stream ecosystem — StreamController, broadcast streams, and the transform operators; in TypeScript those come from libraries like RxJS, not the language.
Isolates → Web Workers
import 'dart:isolate'; Future<int> heavy(int n) async { // Runs on a separate isolate: no shared memory, arguments copied. return Isolate.run(() { var total = 0; for (var i = 0; i < n; i++) total += i; return total; }); } void main() async { print(await heavy(1000)); }
// Parallelism lives OUTSIDE the language: a Web Worker (browser) or // worker_threads (Node) runs in its own thread with no shared scope. // There is no in-language Isolate.run — this is a runtime API, so the // snippet is shown for shape only. const workerSource = `onmessage = (e) => { let total = 0; for (let i = 0; i < e.data; i++) total += i; postMessage(total); };`; console.log("A Worker would run:", workerSource.length, "chars of code");
Both runtimes are single-threaded with an event loop, so async/await never runs code in parallel — it interleaves. For true parallelism Dart spawns an isolate (its own heap, message-passing, no shared memory), and the browser/Node analog is a Web Worker or worker_threads: same "copy the data, no shared state" model, but a runtime API rather than a language feature — so this row is shown for illustration rather than run in place.
Generics & Mapped Types
Generic functions
T firstOr<T>(List<T> items, T fallback) => items.isEmpty ? fallback : items.first; void main() { print(firstOr<int>([], -1)); print(firstOr(['a', 'b'], 'z')); // inferred }
function firstOr<T>(items: T[], fallback: T): T { return items.length === 0 ? fallback : items[0]; } console.log(firstOr<number>([], -1)); console.log(firstOr(["a", "b"], "z")); // inferred
Generic function syntax is nearly identical — <T> after the name, inference at the call site. The one thing to keep in mind is the earlier lesson: TypeScript's T is erased, so inside the body you cannot inspect it (T has no runtime existence), whereas Dart could test items is List<int>.
Bounds: extends
num largest<T extends num>(List<T> values) => values.reduce((a, b) => a > b ? a : b); void main() { print(largest<int>([3, 9, 2])); print(largest<double>([1.5, 0.5])); }
function largest<T extends number>(values: T[]): T { return values.reduce((a, b) => (a > b ? a : b)); } console.log(largest([3, 9, 2])); console.log(largest([1.5, 0.5]));
Dart bounds a type parameter with T extends num; TypeScript uses the same keyword, T extends number. Because number already covers both integers and floats, the two Dart calls collapse into one type. TypeScript's extends bound is more expressive than Dart's, though — it can constrain to object shapes, unions, and even keyof another type, as the next example shows.
Mapped types: Partial, Pick, keyof
This is the corner where TypeScript out-expresses Dart entirely: types computed from other types. Dart's generics cannot describe any of these transformations.
// Dart has no way to derive "all fields optional" or "these keys only" // from an existing type — you hand-write each variant. class User { final String name; final int age; const User(this.name, this.age); } void main() { const user = User('Ada', 36); print('${user.name} ${user.age}'); }
interface User { name: string; age: number; } type PartialUser = Partial<User>; // every field optional type JustName = Pick<User, "name">; // select keys type Keys = keyof User; // "name" | "age" const patch: PartialUser = { age: 37 }; // name omitted, legal const summary: JustName = { name: "Ada" }; console.log(patch.age, summary.name);
TypeScript can compute types: Partial<T> makes every field optional, Pick<T, K> selects a subset, Readonly<T> freezes them, and keyof T yields the union of field names — all derived automatically and kept in sync as the source type changes. Dart has no equivalent; you would write each variant class by hand. This alone is a strong reason web APIs lean on TypeScript.
Conditional types
// No Dart analog: you cannot branch a type on another type. // The closest is an overload-like set of methods, hand-written. void main() { print('Dart resolves types nominally, with no type-level "if".'); }
type ElementType<T> = T extends (infer U)[] ? U : T; type A = ElementType<string[]>; // string type B = ElementType<number>; // number (not an array) const first: ElementType<boolean[]> = true; console.log(first);
A conditional typeT extends X ? A : B — chooses a type based on another type, and infer can even extract a piece of it (here, an array's element type). Combined with mapped types this makes TypeScript's type layer a small functional language of its own. Dart has nothing comparable; its type system is descriptive, not computational.
Errors
try / catch
void main() { try { throw StateError('boom'); } catch (error) { print('caught: $error'); } finally { print('always runs'); } }
try { throw new Error("boom"); } catch (error) { console.log("caught:", (error as Error).message); } finally { console.log("always runs"); }
The structure matches Dart, and neither language has checked exceptions. Two differences bite: TypeScript can throw any value (a string, a number), not just an Error — so the caught binding is typed unknown and must be narrowed before use. And there is no on Type catch clause; type-based dispatch is done with instanceof, shown next.
on Type → instanceof narrowing
void main() { try { throw FormatException('bad input'); } on FormatException catch (error) { print('format problem: ${error.message}'); } on Exception catch (error) { print('other: $error'); } }
class FormatError extends Error {} try { throw new FormatError("bad input"); } catch (error) { if (error instanceof FormatError) { console.log("format problem:", error.message); } else { console.log("other:", error); } }
Dart selects a handler by type with on FormatException catch. TypeScript has a single catch block whose parameter is unknown, so you branch inside it with instanceof — which, unlike an interface check, does work at run time because classes are real values. This is the one place structural erasure does not bite: class identity survives.
Custom exceptions → extends Error
class InsufficientFunds implements Exception { final int shortfall; InsufficientFunds(this.shortfall); @override String toString() => 'Short by $shortfall cents'; } void main() { try { throw InsufficientFunds(150); } catch (error) { print(error); } }
class InsufficientFunds extends Error { constructor(public shortfall: number) { super(`Short by ${shortfall} cents`); this.name = "InsufficientFunds"; } } try { throw new InsufficientFunds(150); } catch (error) { console.log((error as InsufficientFunds).message); }
In Dart any class that implements Exception can be thrown. In TypeScript you conventionally extends Error and call super(message), which gives you a stack trace and a message field for free. Setting this.name is the customary touch so the error prints with your class name — the rough equivalent of overriding toString.
rethrow
void risky() => throw StateError('inner'); void main() { try { try { risky(); } catch (error) { print('logging, then rethrowing'); rethrow; // preserves the original stack trace } } catch (error) { print('outer saw: $error'); } }
function risky(): never { throw new Error("inner"); } try { try { risky(); } catch (error) { console.log("logging, then rethrowing"); throw error; // re-throw the same value } } catch (error) { console.log("outer saw:", (error as Error).message); }
Dart's rethrow keyword re-raises the current exception while preserving its original stack trace. TypeScript has no dedicated keyword — you simply throw error again. Because the caught value is the original Error object (which captured its stack when constructed), the trace is retained; there is just no special syntax marking the intent.
Modules & Tooling
Library imports → ES modules
// geometry.dart // double areaOfCircle(double r) => 3.14159 * r * r; // // main.dart import 'geometry.dart'; void main() { print(areaOfCircle(2)); }
// geometry.ts // export function areaOfCircle(r: number): number { // return 3.14159 * r * r; // } // // main.ts import { areaOfCircle } from "./geometry.ts"; console.log(areaOfCircle(2));
Dart imports whole libraries by path and every top-level name is public unless prefixed with _. TypeScript uses ES modules with the reverse default: nothing leaves a file unless exported, and you import named bindings with { … }. This example spans two files, so it is marked display-only — a single-file test cannot resolve the second module.
No extension methods
extension NumberWords on int { String get spelledOut => this == 1 ? 'one' : 'many'; } void main() { print(3.spelledOut); // 'many' — added a method to int }
// TypeScript cannot add methods to an existing type from the outside. // Write a free function instead: function spelledOut(value: number): string { return value === 1 ? "one" : "many"; } console.log(spelledOut(3));
Dart's extension methods let you graft new methods onto a type you do not own — 3.spelledOut. TypeScript has no such feature for your own code; you write a plain function, spelledOut(3). (Declaration merging can describe methods added to a global prototype, but actually monkey-patching built-ins is discouraged and is not the same tool.)
pub → npm
// pubspec.yaml // dependencies: // http: ^1.2.0 // // Then: dart pub get // Then in code: import 'package:http/http.dart' as http; void main() { print('Dependencies are declared in pubspec.yaml and fetched by pub.'); }
// package.json // { "dependencies": { "zod": "^3.23.0" } } // // Then: npm install // Then in code: import { z } from "zod"; console.log("Dependencies live in package.json and are fetched by npm.");
Dart's pubspec.yaml plus dart pub get corresponds to package.json plus npm install (or pnpm/yarn/bun). The registries differ — pub.dev versus the npm registry — and TypeScript packages often ship types either bundled or as a separate @types/… package, a split Dart never has since every pub package is already typed.