Mert Özen Further With Every Line
Frontend Mobile with Flutter — #03

Dart for C# Developers, Part 1: Types, Null Safety, Collections, Functions and Records

Mert Özen Nov 15, 2026 14 min 4 views
Dart for C# Developers, Part 1: Types, Null Safety, Collections, Functions and Records

Dart's null safety, unlike C#'s, is sound: using a nullable value without a check is a compile error rather than a warning, and a non-nullable variable can never be null at run time. For readers who know C#, we explain var/final/const, how const produces canonical instances, collection if/for and null-aware elements, named parameters and records with real output, and write Bookstore's Book and Author models.

Version note: The Dart examples in this post were run with Dart 3.13.7 (the SDK that ships with Flutter 3.47.8), and the Flutter tests with Flutter 3.47.8. The C# code used for comparison was built with the .NET 10 SDK 10.0.404.

Guess First

Before writing the mobile app of Bookstore, the online bookstore used as the shared example across this site's series, we get to know Dart through the eyes of someone who knows C#. This post is the first half of the language: types, null safety, collections, functions and records. The next post continues with classes and pattern matching.

Two small pieces. The first looks almost the same as C#:

String? findSubtitle(String isbn) => null;

void main() {
  String? subtitle = findSubtitle('9780441172719');
  print(subtitle.length);
}

Does the same code compile in C# with Nullable enabled? Does it compile in Dart? The second:

const a = Author('Jane Austen');
const b = Author('Jane Austen');
final c = Author('Jane Austen');

What do identical(a, b) and identical(a, c) return? We will see both with real output shortly.

var, final and const: Their C# Counterparts

The examples live in the dart_samples package in the series repository. The first file collects variable declarations and null safety:

// var / final / const, sound null safety and late (post 03).
class Author {
  const Author(this.name);
  final String name;
}

String? findSubtitle(String isbn) =>
    isbn == '9780141439549' ? 'A Study of Provincial Life' : null;

late final String apiBaseUrl;

void main() {
  var title = 'Dune'; // type inferred as String
  title = 'Emma'; // var can be reassigned
  final createdAt = DateTime.utc(2026, 11, 15); // set once, at run time
  const pageSize = 20; // compile-time constant
  print('$title, $createdAt, $pageSize');

  // const objects are canonicalized: same values, same instance
  const a = Author('Jane Austen');
  const b = Author('Jane Austen');
  final c = Author('Jane Austen');
  print(
    'identical(a, b): ${identical(a, b)}, identical(a, c): ${identical(a, c)}',
  );

  // final list: the variable is fixed, the list is not
  final cart = ['Dune'];
  cart.add('Emma');
  print('final list: $cart');
  const featured = ['Dune', 'Emma'];
  try {
    featured.add('Middlemarch');
  } on UnsupportedError catch (e) {
    print('const list: $e');
  }

  // Null safety
  String? subtitle = findSubtitle('9780441172719');
  print('length: ${subtitle?.length}, or: ${subtitle ?? '(no subtitle)'}');
  subtitle ??= 'Untitled';
  print('after ??= : $subtitle');
  final middlemarch = findSubtitle('9780141439549')!; // "I know it is not null"
  print('bang: $middlemarch');

  try {
    print(apiBaseUrl);
  } on Error catch (e) {
    print('late: ${e.runtimeType}: $e');
  }
  apiBaseUrl = 'http://127.0.0.1:52813';
  print('late after set: $apiBaseUrl');

  // Why prices are int kurus and not double
  print('double: ${0.1 + 0.2}, ${4.35 * 100}');
  print('int kurus: ${10 + 20}');
}
dart run bin/variables.dart
Emma, 2026-11-15 00:00:00.000Z, 20
identical(a, b): true, identical(a, c): false
final list: [Dune, Emma]
const list: Unsupported operation: Cannot add to an unmodifiable list
length: null, or: (no subtitle)
after ??= : Untitled
bang: A Study of Provincial Life
late: LateError: LateInitializationError: Field 'apiBaseUrl' has not been initialized.
late after set: http://127.0.0.1:52813
double: 0.30000000000000004, 434.99999999999994
int kurus: 30

var is like in C#: the compiler infers the type, which then stays fixed; only the value changes. final has no direct counterpart for local variables in C#: it is assigned once and cannot be reassigned. On fields it corresponds to readonly. In Dart it is common on local variables too; if you turn on the optional prefer_final_locals lint rule, the analyzer asks for final on local variables that never change.

const is much more powerful than in C#. In C#, const exists only for numbers, strings and similar simple types. In Dart, if a class's constructor is const, you can create objects of that class at compile time. On top of that, the compiler canonicalizes these objects: two const objects created with the same values are in fact a single instance. That is why the answer to the second question in "Guess First" is true and false: a and b are the same object, while c is a separate object created at run time with final.

This has a concrete counterpart in Flutter: a widget like const Text('Bookstore') is not recreated on every build; Flutter knows it has not changed and does not rebuild that subtree. We measure this in the sixth post. Also note the difference between a final and a const list: you can add elements to a final list because it is the variable that is fixed; a const list itself cannot be changed, and add throws UnsupportedError at run time.

Sound Null Safety: an Error, Not a Warning

In Dart a String cannot be null; the type that can be null is String?. The nullable reference types that came with C# 8 use the same syntax, but the behaviour is different. We compiled the first piece from "Guess First" in both languages. Dart:

dart analyze
  error - bin/null_error.dart:5:18 - The property 'length' can't be unconditionally accessed because the receiver can be 'null'. Try making the access conditional (using '?.') or adding a null check to the target ('!'). - unchecked_use_of_nullable_value
  error - bin/null_error.dart:6:15 - A value of type 'Null' can't be assigned to a variable of type 'int'. Try changing the type of the variable, or casting the right-hand type to 'int'. - invalid_assignment

The second error comes from the line int stock = null; that we added to the file. dart run does not run this file at all; it stops with a compile error. The C# version of the same code in a .NET 10 project with <Nullable>enable</Nullable>:

string? subtitle = FindSubtitle("9780441172719");
Console.WriteLine(subtitle.Length);

static string? FindSubtitle(string isbn) => null;
Program.cs(2,19): warning CS8602: Dereference of a possibly null reference.
Build succeeded.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.

That is the answer to the first question in "Guess First": C# compiles it, gives a warning and the code crashes at run time with a NullReferenceException; Dart does not compile it. C#'s nullable annotations are hints to the compiler, with no guarantee at run time. Dart's null safety is "sound": a variable whose type is String is never null at run time either, and the compiler can use that knowledge to drop unnecessary null checks. Since Dart 3, code without null safety does not compile; it is not a per-project setting you switch on and off as in C#. The discipline described in the C# series post "Nullable in C#: int?, string? and Managing Null by Design" is enforced by the compiler here.

The operators are familiar from C#. ?. returns null if the receiver is null, ?? gives the right-hand side if the left is null, and ??= assigns if the variable is null. ! resembles C#'s null-forgiving operator with one difference: in C# ! only silences the warning, while in Dart it performs a check at run time and throws immediately if the value is null.

late is the promise "I will give this variable its value later and will not read it before that". The compiler postpones the check to run time; if you break the promise you get the LateInitializationError in the output. You use late for values provided later through dependency injection or for controllers set up in initState. Silencing every null error with late, however, turns the guarantee that sound null safety gives you back into a run-time error.

Numbers and Money: int, double and Why Kurus

Dart has two number types: int and double. On mobile and desktop int is a 64-bit integer, the same as long in C#. On the web it compiles to JavaScript's number type and stays exact only up to 53 bits; that is why carrying large IDs as strings on the web is safer. double is an IEEE 754 double-precision number, the same as double in C#.

Dart's core library has no counterpart to C#'s decimal. The Bookstore API keeps prices as decimal; putting the price into a double on the mobile side gives the result in the output: 0.1 + 0.2 is 0.30000000000000004 and 4.35 * 100 is 434.99999999999994. In a cart total, such drifts of a kurus mean showing the user a wrong amount. In this series we keep money as an int in kurus: 489.00 TRY is stored as 48900 and formatted only when shown on screen. In the next post we make this type-safe with an extension type. If you really need decimal arithmetic, there is a decimal package on pub.dev.

Collections: List, Set, Map and Collection if/for

Dart's three core collections are List, Set and Map, the counterparts of C#'s List<T>, HashSet<T> and Dictionary<TKey, TValue>. The real difference is that you can write code inside the literals:

// List / Set / Map, collection if/for, spread, null-aware elements, digit separators, wildcards (post 03).
void main() {
  final titles = <String>['Dune', 'Emma', 'Dune'];
  final unique = <String>{...titles}; // Set removes the duplicate
  final stock = <String, int>{'Dune': 12, 'Emma': 0};
  print('list: $titles, set: $unique, map: $stock');

  // collection if and collection for build the list in place
  const showOutOfStock = false;
  final visible = [
    for (final entry in stock.entries)
      if (showOutOfStock || entry.value > 0) '${entry.key} (${entry.value})',
  ];
  print('visible: $visible');

  // spread and null-aware spread
  List<String>? recentlyViewed;
  final menu = ['Catalog', ...?recentlyViewed, 'Cart'];
  print('menu: $menu');

  // null-aware element (Dart 3.8): add only if not null
  String? subtitle;
  const coupon = 'WELCOME10';
  final chips = ['Dune', ?subtitle, ?couponOrNull(coupon)];
  print('chips: $chips');

  // digit separators (Dart 3.6)
  const freeShippingLimit = 100_000; // 1,000.00 TRY in kurus
  print('limit: $freeShippingLimit');

  // wildcard (Dart 3.7): `_` does not bind, so it can repeat
  print(withSeparators(['Dune', 'Emma', 'Middlemarch'], (_, _) => '---'));
}

String? couponOrNull(String code) => code.isEmpty ? null : code;

List<String> withSeparators(
  List<String> titles,
  String Function(String title, int index) separator,
) => [
  for (var i = 0; i < titles.length; i++) ...[
    titles[i],
    if (i < titles.length - 1) separator(titles[i], i),
  ],
];
list: [Dune, Emma, Dune], set: {Dune, Emma}, map: {Dune: 12, Emma: 0}
visible: [Dune (12)]
menu: [Catalog, Cart]
chips: [Dune, WELCOME10]
limit: 100000
[Dune, ---, Emma, ---, Middlemarch]

Collection if and for do inside the literal what you would do with LINQ or a separate loop in C#. We will use this constantly in Flutter: adding a widget to a Column's children list based on a condition, or producing widgets from a list, is exactly this syntax. ... (spread) unpacks another collection into this one, and ...? adds nothing if the collection is null.

Dart 3.8's null-aware element ?subtitle is the shortest way to say "add it if it is not null"; before, we wrote if (subtitle != null) subtitle for this. Dart 3.6's digit separator 100_000 is the same as 100_000 in C#. Dart 3.7's wildcard _ resembles C#'s discard: it does not bind the value, so you can write more than one _ in the same parameter list. The place you see it most in Flutter is callbacks like (_, _) => const Divider().

Functions: Named, Optional and required Parameters

// Named and optional parameters, arrow functions, closures and records (post 03).
String formatPrice(
  int kurus, {
  String currency = 'TRY',
  bool showCurrency = true,
}) {
  final value = '${kurus ~/ 100}.${(kurus % 100).toString().padLeft(2, '0')}';
  return showCurrency ? '$value $currency' : value;
}

String describe(String title, {required int year, String? subtitle}) =>
    subtitle == null ? '$title ($year)' : '$title: $subtitle ($year)';

String shortTitle(String title, [int maxLength = 10]) =>
    title.length <= maxLength ? title : '${title.substring(0, maxLength)}...';

// A closure keeps the variables of the scope it was created in.
int Function() counter() {
  var count = 0;
  return () => ++count;
}

// A record is an anonymous, immutable value with structural equality.
({int items, int total}) summarize(List<(String, int)> lines) {
  var total = 0;
  for (final (_, price) in lines) {
    total += price;
  }
  return (items: lines.length, total: total);
}

void main() {
  print(formatPrice(48900));
  print(formatPrice(48900, showCurrency: false));
  print(
    describe('Middlemarch', year: 1871, subtitle: 'A Study of Provincial Life'),
  );
  print(shortTitle('The Hobbit, or There and Back Again'));

  final next = counter();
  next();
  next();
  print('counter: ${next()}');

  final summary = summarize([('Dune', 48900), ('Emma', 21500)]);
  print('summary: $summary');
  final (:items, :total) = summary; // destructuring by field name
  print('items=$items total=${formatPrice(total)}');

  print('records equal: ${(items: 2, total: 70400) == summary}');
  print('runtimeType: ${summary.runtimeType}');
}
489.00 TRY
489.00
Middlemarch: A Study of Provincial Life (1871)
The Hobbit...
counter: 3
summary: (items: 2, total: 70400)
items=2 total=704.00 TRY
records equal: true
runtimeType: ({int items, int total})

In C# every parameter is required except the ones you give a default value, and named arguments are the caller's choice. Dart has two separate kinds. Parameters inside {} are named and must be passed by name; they are optional by default, and you write required to make one mandatory. Parameters inside [] are optional positional parameters. Flutter's API is built almost entirely on named parameters: Text('Dune', style: ..., maxLines: 1). That is why you will get used to seeing required in constructors.

=> is for a single-expression body, the same as an expression-bodied member in C#. Functions are first-class values: they are assigned to variables, passed as parameters, and their type is written like int Function(), the counterpart of C#'s Func<int>. The closure in the counter() example works like a variable captured by a C# lambda: count increases on every call and cannot be reached from outside.

Records and Destructuring

Records, which arrived with Dart 3.0, are the way to carry several values without defining a class. (String, int) is a positional record, ({int items, int total}) a record with named fields. They resemble C# tuples, with two important differences. C#'s ValueTuple is a mutable struct and its field names exist only at compile time; a Dart record is immutable and its field names are part of the type: the runtimeType in the output is ({int items, int total}). Equality is structural: two records with the same fields and values are equal with ==, as records equal: true in the output shows.

Destructuring is the counterpart of deconstruction in C#. final (:items, :total) = summary; unpacks the named fields into variables with the same names; for (final (_, price) in lines) skips the first field of a positional record with a wildcard. Do not confuse these with the records in the C# series: C#'s record class is a kind of class, while Dart's record is an anonymous value type. The closest thing in Dart to C#'s record is the immutable class with an overridden == that we see in the next post.

The First Version of the Bookstore Models

We bring what we learned into bookstore_app. In Flutter's official architecture guide, domain models live under lib/domain/models/:

class Author {
  const Author({required this.name, this.birthYear});

  final String name;
  final int? birthYear;
}
import 'author.dart';

class Book {
  const Book({
    required this.isbn,
    required this.title,
    required this.author,
    required this.price,
    required this.publishedYear,
    this.subtitle,
    this.coverUrl,
  });

  final String isbn;
  final String title;
  final String? subtitle;
  final Author author;

  /// Price in kurus (1 TRY = 100 kurus); money is never a double.
  final int price;
  final int publishedYear;
  final String? coverUrl;

  String get displayTitle => subtitle == null ? title : '$title: $subtitle';
}

typedef CartLine = ({Book book, int quantity});

({int itemCount, int total}) summarizeCart(List<CartLine> lines) {
  var itemCount = 0;
  var total = 0;
  for (final (:book, :quantity) in lines) {
    itemCount += quantity;
    total += book.price * quantity;
  }
  return (itemCount: itemCount, total: total);
}

String formatTry(int kurus) =>
    '${kurus ~/ 100}.${(kurus % 100).toString().padLeft(2, '0')} TRY';

All fields are final and the constructor is const: the model is immutable and can be created at compile time. The this.isbn syntax assigns the parameter straight to the field; it replaces the Isbn = isbn; lines you would write in a C# constructor body. Optional information (subtitle, coverUrl, birthYear) is nullable. We did not write a class for the cart line; we named a record type with typedef. The tests:

test('summarizeCart counts items and sums prices in kurus', () {
  final summary = summarizeCart([
    (book: dune, quantity: 2),
    (book: middlemarch, quantity: 1),
  ]);
  expect(summary, (itemCount: 3, total: 125300));
  expect(formatTry(summary.total), '1253.00 TRY');
});

test('const books with the same values are the same instance', () {
  const again = Book(
    isbn: '9780441172719',
    title: 'Dune',
    author: herbert,
    price: 48900,
    publishedYear: 1965,
  );
  expect(identical(dune, again), isTrue);
});
flutter test
00:00 +0: test/domain/models/book_test.dart: displayTitle adds the subtitle only when there is one
00:00 +1: test/domain/models/book_test.dart: summarizeCart counts items and sums prices in kurus
00:00 +2: test/domain/models/book_test.dart: const books with the same values are the same instance
00:00 +3: test/widget_test.dart: tapping the button adds a book to the cart
00:00 +4: All tests passed!

Record equality keeps the test short: expect(summary, (itemCount: 3, total: 125300)) compares both fields in one line.

In Production

Apply the rule of not using double for money at the API boundary too. The Bookstore API sends the price in JSON as a number like 489.00; Dart's jsonDecode reads it as a double. Convert to kurus in one place, where the model is created from JSON, and choose the rounding explicitly. We will see this with a real response in the API integration post.

Flag uses of ! and late in code review. Both move a check the compiler would do to run time. If you see many ! in a file, a type is usually declared nullable where it should not be.

If you also build for the web, remember the 53-bit limit of int. Keeping a 13-digit value such as an ISBN-13 in an int works fine on mobile and stays within that range on the web too; but carrying a 64-bit ID from the database as an int can silently produce a wrong value on the web. Keeping identifiers as strings is safe on both platforms.

Common Pitfalls

The first pitfall is thinking a final list is immutable. When you write final cart = ['Dune']; you cannot assign another list to cart, but you can add elements to it. If you really want a collection that does not change, use const or List.unmodifiable.

The second pitfall is using ! out of C# habit to mean "silence the warning". In Dart ! is a run-time check; if the value is null, the app throws on that line. In Flutter that means a red error screen (debug) or a grey area (release) on the user's screen.

The third pitfall is forgetting that named parameters are optional by default. If you write {int year}, the compiler reports an error because a non-nullable parameter has no default value; the fix is either required or a default value. Making it nullable (int? year) is usually the wrong fix.

The fourth pitfall is using records instead of classes everywhere. Records are good for returning several values from a function or for short-lived data; a domain model needs a name, behaviour and validation, which means a class.

A Small Experiment

★☆☆ In variables.dart, write final featured = List.unmodifiable(['Dune', 'Emma']) instead of const featured. Does the output change? Compare two List.unmodifiable results with identical.

★★☆ Add free-shipping information to summarizeCart: make the return type ({int itemCount, int total, bool freeShipping}) and return true above 1,000.00 TRY. Write the limit with a digit separator and add a test.

★★★ Add List<String> tags to Book and keep the const constructor. What happens when you pass tags: ['classic'] to the dune constant in the test, and what happens with tags: const ['classic']? Does the identical(dune, again) test still pass in either case?

In the next post we move to the second half of Dart: the kinds of constructors and Dart 3.13's primary constructors, every class also being an interface, mixins, extension types, and pattern matching with a sealed class that models the order status.