Chương 05 · Null Safety & Error Handling

Null Safety & Error Handling

Sound null safety là fact của Dart — không thể tránh. Mỗi field widget, mỗi response API, mỗi state value đều phải quyết định "có nullable không". Sai null safety = crash runtime hoặc analyzer scream. Đây là chương "lái xe an toàn" — không nắm thì code sẽ đầy ?! random.

Độ dài: ~900 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 2, 4
🎯 Mục tiêu chương
  • Hiểu sound null safety là gì, vì sao Dart 2.12+ làm điều này.
  • Phân biệt T (non-nullable) và T? (nullable).
  • Dùng đủ 5 null-aware operator: ?., ??, ??=, ...?, ?[].
  • Dùng ! non-null assertion đúng chỗ + hiểu rủi ro.
  • Hiểu late keyword: late init + lazy late.
  • Phân biệt Exception vs Error.
  • Bắt exception đúng: try/catch/finally, on Type catch, rethrow.
  • Tự định nghĩa exception class.
  • Xử lý lỗi async với try/catch + await.

1. Vì sao có null safety?

Năm 2009, Tony Hoare — người tạo ra null reference năm 1965 — gọi đây là "billion dollar mistake". Lý do: null reference gây ra uncountable bug runtime ở mọi ngôn ngữ có nó (Java, C#, JS, Python).

Dart trước version 2.12 (2021): có null tự do — biến bất kỳ có thể là null. Mỗi access có thể throw.

Dart 2.12+: sound null safety. Compile-time guarantee: nếu type là T (không ?), giá trị không bao giờ là null. Mọi nullable phải explicit T?.

🧠 "Sound" nghĩa là gì?

"Sound" = analyzer chứng minh được — không phải "lint cảnh báo". Code compile được nghĩa là null safety đảm bảo. TS strict mode chỉ "cảnh báo" — Dart 3 cấm.

Hệ quả: ở Dart code mà NullPointerException kiểu Java không tồn tại. Bạn không bao giờ phải debug "tại sao biến này lại null".

2. Nullable T? vs non-nullable T

// Non-nullable — không bao giờ null
int count = 0;
count = null;       // ❌ Lỗi compile

// Nullable — có thể null
int? maybeCount = 0;
maybeCount = null;  // OK

// Default value cho nullable: null
String? name;       // = null mặc định
print(name);        // null

// Non-nullable chưa gán → lỗi compile khi đọc
String message;
print(message);     // ❌ "Non-nullable variable 'message' must be assigned"

3. Definite assignment analysis

Analyzer kiểm tra biến non-nullable phải được gán chắc chắn trước khi đọc:

void demo(int input) {
  String name;
  if (input > 0) {
    name = 'positive';
  } else if (input < 0) {
    name = 'negative';
  } // ⚠ Thiếu case input == 0
  print(name);  // ❌ Lỗi — không chắc chắn gán
}

Fix: thêm else hoặc gán default:

void demo(int input) {
  String name;
  if (input > 0) {
    name = 'positive';
  } else if (input < 0) {
    name = 'negative';
  } else {
    name = 'zero';
  }
  print(name);  // ✅
}

4. 5 null-aware operator

?. — Safe call

String? name;
print(name?.length);  // null (không throw)

name = 'Việt';
print(name?.length);  // 4

// Chain
print(user?.address?.city?.toLowerCase());

?? — Default value

String? name;
final displayName = name ?? 'Khách';  // 'Khách'

name = 'Việt';
final d2 = name ?? 'Khách';            // 'Việt'

??= — Assign nếu null

String? name;
name ??= 'default';   // gán vì null
print(name);          // 'default'

name ??= 'override';  // không gán vì đã có giá trị
print(name);          // 'default'

...? — Spread null-safe

List<int>? maybeXs;
final combined = [0, ...?maybeXs, 99];
print(combined);   // [0, 99] — maybeXs null bị skip

maybeXs = [1, 2, 3];
final c2 = [0, ...?maybeXs, 99];
print(c2);          // [0, 1, 2, 3, 99]

?[] — Index null-safe

Map<String, int>? map;
print(map?['key']);   // null (map null)

map = {'a': 1};
print(map?['a']);     // 1
print(map?['b']);     // null (key không tồn tại)

Bảng tóm tắt 5 null-aware operator:

OperatorKhi trái nullKhi trái không nullUse case
a?.method()Trả nullGọi method bình thườngChain method an toàn
a ?? bTrả bTrả aDefault value
a ??= bGán a = bKhông gánLazy init
...?aSkip (rỗng)Spread aCompose collection optional
a?[key]Trả nullIndex bình thườngMap/List optional

! — Non-null assertion

! promote T? thành T. Nếu thực sự là null → runtime exception.

String? maybeName = getName();

// Dùng ! khi BẠN biết chắc không null nhưng analyzer không
print(maybeName!.length);  // promote → String, throw nếu null

// Cẩn thận — đây là escape hatch
final users = <User>[];
final first = users.firstOrNull!;  // throw vì list rỗng
⚠️ Tránh ! nếu được

! bypass null safety guarantee — runtime risk. Pattern tốt hơn:

  • Check null trước → flow promotion: if (x != null) x.method();
  • Default value: (x ?? defaultValue).method()
  • Early return: if (x == null) return;

Chỉ dùng ! khi analyzer không đủ smart để chứng minh non-null (ví dụ value từ Map sau check containsKey).

5. Flow analysis / Type promotion

Analyzer thu hẹp type sau check null:

void demo(String? name) {
  if (name != null) {
    // Trong block này, name PROMOTED thành String
    print(name.length);   // ✅ — không cần !
  }

  // Pattern early return cũng work
  if (name == null) return;
  print(name.length);     // ✅ — sau early return, chắc chắn không null
}
🔥 Hạn chế: chỉ work với local variable

Field của class không được promote (vì method khác có thể đổi field giữa chừng):

class User {
  String? name;

  void show() {
    if (name != null) {
      print(name.length);  // ❌ — field không promoted
    }

    // Pattern: copy field ra local
    final n = name;
    if (n != null) {
      print(n.length);     // ✅ — local n được promote
    }
  }
}

6. late keyword

Late initialization

class User {
  late String name;       // "tôi hứa sẽ gán trước khi đọc"

  void init(String v) {
    name = v;
  }
}

final u = User();
// print(u.name);   // ❌ Runtime: LateInitializationError
u.init('Việt');
print(u.name);     // ✅ Việt

late final — lazy init

class Config {
  late final String token = _loadToken();  // chỉ gọi khi đọc lần đầu

  String _loadToken() {
    print('Loading...');
    return 'abc123';
  }
}

final c = Config();
print('created');    // created
print(c.token);       // Loading... abc123
print(c.token);       // abc123 (không loading lại)
💡 Use case late
  • Dependency injection: late final Database db; set ở runtime.
  • Cyclic init: 2 object reference nhau — không thể init đồng thời.
  • Lazy expensive computation: chỉ tính khi cần.
  • StatefulWidget Flutter: late final controller = TextEditingController();.

7. Exception vs Error

// Exception — lỗi có thể recover, nên catch
throw FormatException('Invalid JSON');
throw HttpException('404 Not Found');

// Error — bug programming, KHÔNG nên catch
throw RangeError('Index out of range');
throw StateError('Object in invalid state');
throw ArgumentError('value cannot be negative');

Bảng:

ExceptionError
SemanticLỗi có thể xảy ra, dev có thể handleBug programming, dev cần FIX code
Ví dụFormatException, HttpException, FileSystemExceptionRangeError, ArgumentError, StateError, AssertionError
Catch?✅ Có — pattern thường gặp❌ Không nên — sửa code thay vì catch
Implementsimplements Exceptionextends Error

8. try / catch / finally

try {
  final result = int.parse('abc');
} catch (e) {
  print('Lỗi: $e');              // FormatException
} finally {
  print('Cleanup');              // Luôn chạy
}

on Type catch — bắt type cụ thể

try {
  doStuff();
} on FormatException catch (e) {
  print('Parse fail: ${e.message}');
} on HttpException catch (e) {
  print('Network fail: $e');
} on Exception catch (e) {
  print('Generic exception: $e');
} catch (e, stackTrace) {
  print('Unknown: $e');
  print(stackTrace);
}

rethrow — giữ stack trace

int parsePositive(String s) {
  try {
    final n = int.parse(s);
    if (n < 0) throw ArgumentError('Negative not allowed');
    return n;
  } on FormatException {
    print('Log: parse failed for "$s"');
    rethrow;        // throw lại, giữ stack trace gốc
  }
}

rethrow khác throw e: rethrow giữ stack trace gốc — dev debug được nơi exception sinh ra ban đầu. throw e tạo stack trace mới ở dòng throw.

9. Custom exception class

class ApiException implements Exception {
  final int statusCode;
  final String message;

  const ApiException(this.statusCode, this.message);

  @override
  String toString() => 'ApiException($statusCode): $message';
}

Future<User> fetchUser(int id) async {
  final response = await http.get(...);
  if (response.statusCode != 200) {
    throw ApiException(response.statusCode, response.body);
  }
  return User.fromJson(response.body);
}

// Usage
try {
  final u = await fetchUser(1);
} on ApiException catch (e) {
  print('API lỗi: ${e.statusCode}');
}
📘 implements Exception, không extends

Exception Dart là interface (abstract class), không phải class concrete. Idiomatic implements Exception. Error là class concrete — extends Error khi tạo custom error.

10. Async error handling

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  throw Exception('Network down');
}

// Cách 1: try/catch quanh await — idiomatic
Future<void> main() async {
  try {
    final data = await fetchData();
    print(data);
  } catch (e) {
    print('Lỗi: $e');
  }
}

// Cách 2: .catchError — legacy nhưng vẫn dùng được
void main2() {
  fetchData()
    .then((data) => print(data))
    .catchError((e) => print('Lỗi: $e'));
}
⚠️ Async error trong callback không await — unhandled

Flutter pitfall: callback (vd onPressed) không await, nếu throw → unhandled async exception:

ElevatedButton(
  onPressed: () async {
    await fetchData();   // nếu throw, không có gì catch
  },
  ...
)

// Pattern an toàn: wrap try/catch trong callback
ElevatedButton(
  onPressed: () async {
    try {
      await fetchData();
    } catch (e) {
      _showError(e.toString());
    }
  },
  ...
)

11. Bài tập

displayCity với null-aware

Cho model:

class User { final String name; final Address? address; ... }
class Address { final String? city; ... }

Viết String displayCity(User u) trả về city hoặc 'Không có địa chỉ'. Dùng null-aware operator, không dùng if/else.

💡 Gợi ý đáp án
String displayCity(User u) =>
    u.address?.city ?? 'Không có địa chỉ';

Single line. ?. safe chain, ?? default.

Refactor ! → if-check

Refactor đoạn code dùng ! quá nhiều thành code an toàn:

void show(User? u) {
  print(u!.name!.toUpperCase());
  print(u!.address!.city!);
}
💡 Gợi ý đáp án
void show(User? u) {
  if (u == null) return;
  print(u.name?.toUpperCase() ?? '(không tên)');
  print(u.address?.city ?? '(không địa chỉ)');
}

Early return + null-aware. Không còn ! nào.

late final Database injection

Implement pattern late final Database db với factory để inject database. Test: gọi db.query() trước khi setDb → expect runtime LateInitializationError.

💡 Gợi ý đáp án
class App {
  late final Database db;

  void setDb(Database d) {
    db = d;
  }
}

void main() {
  final app = App();
  try {
    app.db.query();  // throws LateInitializationError
  } catch (e) {
    print('Error: $e');
  }

  app.setDb(Database());
  app.db.query();  // OK
}

Custom ApiException

Định nghĩa class ApiException với 3 field: statusCode, message, endpoint. Override toString. Throw từ function fetchUser mock, catch ở caller, log.

💡 Gợi ý đáp án
class ApiException implements Exception {
  final int statusCode;
  final String message;
  final String endpoint;
  const ApiException(this.statusCode, this.message, this.endpoint);

  @override
  String toString() => 'ApiException($statusCode @ $endpoint): $message';
}

Future<String> fetchUser(int id) async {
  if (id < 0) throw ApiException(400, 'Invalid id', '/users/$id');
  return 'user_$id';
}

void main() async {
  try {
    await fetchUser(-1);
  } on ApiException catch (e) {
    print(e);
  }
}

Async fetch with 3-tier error

Viết async function fetchAndParse(String url) handle 3 loại lỗi: network (NetworkException), parse (FormatException), khác (generic catch). Mỗi loại trả fallback khác.

💡 Gợi ý đáp án
class NetworkException implements Exception {}

Future<Map> fetchAndParse(String url) async {
  try {
    final response = await mockFetch(url);
    return jsonDecode(response);
  } on NetworkException {
    return {'error': 'offline'};
  } on FormatException {
    return {'error': 'invalid response'};
  } catch (e) {
    return {'error': 'unknown: $e'};
  }
}

12. Quiz

Q1

int x = null; ở Dart 3 — chuyện gì?

Xem đáp án

Đáp án: Lỗi compile. int non-nullable. Phải dùng int? hoặc gán giá trị khác null.

Q2

late String name; chưa gán, đọc — chuyện gì?

Xem đáp án

Đáp án: Lỗi runtime LateInitializationError: Field 'name' has not been initialized. Compile OK vì late = hứa sẽ gán; runtime kiểm tra.

Q3

obj!.method()! làm gì?

Xem đáp án

Đáp án: Promote T? thành T. Tại runtime: nếu thực sự null → throw TypeError: Null check operator used on a null value.

Q4

a ?? b — khi nào trả b?

Xem đáp án

Đáp án: Khi anull. Khác với JS || (also trả khi a là 0/''/false). Dart ?? chỉ kiểm null.

Q5

a ??= b — khi nào assign?

Xem đáp án

Đáp án: Khi a đang null. Nếu a đã có giá trị → không gán.

Q6

Sau if (x != null) { ... x.method(); ... } với x là local int? — trong block x cần ! không?

Xem đáp án

Đáp án: Không. Flow promotion: local variable sau check null được tự promote thành non-null. Nhưng nếu x là field, cần copy ra local trước.

Q7

Catch Error (vd RangeError) — nên hay không?

Xem đáp án

Đáp án: Không nên. Error là bug programming — nên FIX code, không catch. Catch chỉ với Exception family.

Q8

rethrow khác throw e ở điểm gì?

Xem đáp án

Đáp án: rethrow giữ stack trace gốc — debug đúng nơi exception sinh ra. throw e tạo stack trace mới ở dòng throw, mất context gốc.

13. Tổng kết

  • ✅ Sound null safety = compile-time guarantee.
  • T? nullable, T non-nullable. Mọi escape phải explicit.
  • ✅ 5 null-aware operator: ?., ??, ??=, ...?, ?[].
  • ! non-null assertion — bypass nhưng runtime risk.
  • ✅ Flow promotion — chỉ work với local var (không field).
  • late = "hứa sẽ gán"; late final = expr = lazy init.
  • Exception vs Error — semantic khác.
  • try/catch/on/finally/rethrow.
  • ✅ Async error: try/catch quanh await idiomatic.

14. Kết nối