Dart 2.12 introduced sound null safety. This is not a nullable annotation system bolted onto an Existing type system. It is a fundamental rewrite of the type hierarchy: Null is a subtype of Every type, but only of nullable types. The compiler and runtime together guarantee that a Non-nullable variable never holds null at runtime. This guarantee is sound — it holds across Function boundaries, class hierarchies, generic instantiations, and asynchronous code paths.
Definition. Soundness (in the type-theoretic sense) means: if the type checker accepts a Program, no runtime type error related to null dereference can occur. The guarantee is global — it Does not depend on the programmer annotating every variable correctly, because the type checker Enforces consistency at all boundaries.
Unsound null safety (e.g., TypeScript”s strictNullChecksKotlin’s platform types at interop Boundaries) means the compiler can miss cases. A variable declared non-nullable might still be null at runtime due to unchecked casts, interop boundaries, or generics erasure. Dart’s null Safety is sound because:
Null is a proper subtype, not a special sentinel value.Generic types are reified — List<int> and List<int?> are distinct at runtime. Flow analysis is global across function boundaries (within compilation units). The runtime enforces null checks at trusted boundaries (e.g., native FFI). Every type T in Dart 2.12+ exists in two forms: T (non-nullable) and T? (nullable). The Nullable form is a union type: T? is equivalent to T | Null.
String name = 'Dart' ; // non-nullable, must be initialized to non-null
String ? maybeName = null ; // nullable, can hold String or null
String ? alsoName; // nullable, implicitly initialized to null
int count = 42 ; // non-nullable
int ? maybeCount; // nullable, implicitly null
List < String > names = [ 'a' ]; // non-nullable list, must be non-null, elements non-null
List < String ?> mixed = [ 'a' , null ]; // non-nullable list, elements can be null
List < String > ? maybeNames = null ; // nullable list reference
Definition. The nullability hierarchy: Never is a subtype of NullWhich is a subtype of Every TWhich is a subtype of T?Which is a subtype of Object?. Object is the supertype of All non-nullable types. Object? is the top type.
Never (bottom — no values inhabit this type)
Null (only value is null)
int, String, ... (non-nullable types)
Object (supertype of all non-nullable types)
Object? (top type — every value is an Object?)
The ! operator asserts to the type checker that an expression of type T? is non-null at this Point. It has no runtime effect other than throwing an AssertionError if the value is actually null.
String ? getName () => 'Dart' ;
String ? name = getName ();
// name has static type String?
String definite = name ! ; // static type is now String
// If name were null at runtime, throws: Null check operator used on a null value
When ! is safe:
// Safe: you just checked for null
String ? name = getName ();
// Flow analysis promotes name to String here — ! is unnecessary
// Safe: the value is provably non-null from API contract
final list = < String ?> [ 'a' , 'b' , null ];
String first = list.first ! ; // Safe if you know the list is non-empty and first is non-null
When ! is dangerous:
// Dangerous: depends on external state that could change
String getValue () => cached ! ; // Crashes if init() was never called
// Dangerous: depends on list contents you don't control
List < String ?> external = fetchFromNetwork ();
String first = external .first ! ; // Crashes if first element is null
Promote the type via flow analysis, remove the `!`. If it cannot, prefer a guard or default value. The `!` operator is a code smell in production code — it means you are bypassing the safety system.The late keyword tells the compiler: “this variable will be initialized before it is used, but not At the point of declaration.” The compiler trusts this assertion and does not require an Initializer. At runtime, accessing an uninitialized late variable throws a LateInitializationError.
late Connection _connection;
void init ( String host, int port) {
_connection = Connection (host, port);
// _connection must have been initialized via init() before this call
_connection. execute (sql);
This pattern is common in classes where initialization depends on constructor arguments or Configuration that is not available at field declaration time. The late keyword avoids making the Field nullable, which would require null checks everywhere it is used.
late final String environment;
environment = Platform .environment[ 'ENV' ] ?? 'development' ;
port = int . parse ( Platform .environment[ 'PORT' ] ?? '8080' );
late final allows exactly one assignment. After assignment, the field behaves like a final field — any subsequent assignment throws at runtime. This is the correct pattern for computed properties That are expensive and should be cached.
When late is applied to a top-level or instance variable with an initializer, the initializer runs Lazily — on first access, not at declaration time.
// The expensive computation runs only when heavyResource is first accessed
late final Resource heavyResource = Resource ( expensiveSetup ());
// Computed lazily on first access to _cache
late final Map < String , int > _cache = _buildCache ();
Map < String , int > _buildCache () {
return { 'key' : computeValue ()};
Initialization, always use `late final`. A bare `late` field is mutable and can be reassigned Arbitrarily after its first initialization. Initialization paths and one path forgets to initialize the `late` field, you get a `LateInitializationError` at runtime with no compile-time warning.Returns the left-hand side if it is non-null, otherwise evaluates and returns the right-hand side.
String displayName = name ?? 'Anonymous' ; // 'Anonymous'
int value = count ?? 0 ; // 0
// The right side is only evaluated if the left is null
int result = count ?? computeDefault (); // prints nothing if count is non-null
Assigns the right-hand side to the left-hand side only if the left-hand side is null. Returns the Final value of the left-hand side.
name ??= 'default' ; // name is now 'default'
name ??= 'other' ; // name remains 'default' — assignment skipped
// Useful for lazy initialization of mutable fields
List < int > get items => _items ??= [];
Returns null if the receiver is nullOtherwise accesses the member.
int ? length = name ? .length; // null — name is null
String ? upper = name ? . toUpperCase (); // null
String ? city = user ? .address ? .city; // null-safe through entire chain
Applies cascade operations only if the receiver is non-null. Returns null if the receiver is null.
// If name is null, result is null and no methods are called
Dart distinguishes between optional named parameters (which may be omitted and may be nullable) and Required named parameters (which must be provided by the caller).
// Before null safety: optional named parameters could be null
void oldStyle ({ String name}) {
// name could be null — no compile-time guarantee
// After null safety: required enforces non-null at call site
void newStyle ({ required String name, required int age}) {
// name and age are guaranteed non-null
void withDefault ({ String name = 'unknown' , int age = 0 }) {
// name is non-nullable because the default provides a value
void optionalNullable ({ String ? name}) {
// name may be null — caller can omit or pass null
The required keyword is orthogonal to nullability. A required parameter can have a nullable type (if the caller must explicitly pass null), but in practice required is used with non-nullable Types to enforce that the caller provides a value.
// required with nullable type — caller must pass something (including null)
void example ({ required String ? name}) {
// name is nullable, but caller cannot omit it
example (name : null ); // valid
example (); // compile error: missing required parameter
Definition. Never is the bottom type of the Dart type system. No value has type Never. It is The return type of functions that never return normally (they always throw or loop forever).
Never is a subtype of every type. This makes it useful for exhaustive analysis in pattern matching And control flow.
// A function that never returns
Never fail ( String message) {
throw StateError (message);
// Using Never in exhaustive pattern matching
String describe ( Object value) {
return 'integer: $ value ' ;
} else if (value is String ) {
// The type checker knows value cannot be int or String here
// Returning Never from the else branch proves exhaustiveness
return fail ( 'unexpected type: ${ value . runtimeType } ' );
Never in switch expressions:
int eval ( Expr expr) => switch (expr) {
IntLiteral (value : final v) => v,
AddExpr (left : final l, right : final r) => eval (l) + eval (r),
// No default needed — Never return from exhaustive handler
Dart’s flow analysis tracks nullability through local variables. If you check a nullable variable For non-null, the type is promoted within the scope where the check holds.
String ? name = getName ();
// name is promoted to String here
print (name.length); // no null check needed
// name is String? again outside the if block
Limitations of flow analysis:
// 1. Closures capture the variable, not the promoted type
String ? name = getName ();
// name is promoted to String
print (name.length); // ERROR: name might have been reassigned
// 2. Field promotion is limited
// _name is promoted locally, but...
// If another method could modify _name, promotion is fragile
// 3. Across function boundaries — no promotion
String ? getName () => 'Dart' ;
// getName() returns String? — the caller cannot assume non-null
// even if the implementation always returns non-null
Local is captured by a closure that could be invoked after the variable is nulled, the type checker Will not promote it inside the closure.When migrating legacy Dart code to null safety:
Add ? to types that can be null. This is the safest first step. It makes the type system honest about what the code actually does.Replace ! assertions with guard clauses. Every ! is a potential crash site. Guard clauses make the handling explicit.Use late for fields initialized outside the constructor. This avoids making fields nullable when they are logically non-nullable.Add required to named parameters that callers must provide. Use default values for optional parameters that should not be nullable. User ({ this .name, this .email, this .age});
// After null safety migration
Definition. dart:ffi (Foreign Function Interface) is a Dart library that allows Dart code to Call C functions directly, without writing glue code in an intermediate language. It provides Bindings for C types, pointers, structs, callbacks, and dynamic library loading. It is the mechanism By which Dart interacts with native system libraries, operating system APIs, and Performance-critical C/C++ code.
dart:ffi is available on native platforms (iOS, Android, macOS, Windows, Linux). It is not Available on the web (use WASM interop instead).
The entry point for FFI is DynamicLibraryWhich represents a loaded shared library.
// Open a platform library by name
final dylib = DynamicLibrary . open ( 'libexample.so' ); // Linux
final dylib = DynamicLibrary . open ( 'example.dll' ); // Windows
final dylib = DynamicLibrary . open ( 'libexample.dylib' ); // macOS
// Open a library bundled with the application
final dylib = DynamicLibrary . process (); // symbols from the current process
Definition. DynamicLibrary.open loads a shared library by path. The path resolution follows Platform conventions: on Linux, LD_LIBRARY_PATH is searched; on macOS, DYLD_LIBRARY_PATH; on Windows, the system search path. DynamicLibrary.process() gives access to symbols exported by the Running process itself.
To call a C function from Dart, you define two typedefs: one for the C signature using FFI types, And one for the Dart-side function type. Then you look up the function pointer in the dynamic Library and cast it.
// C typedef: describes the native function signature
typedef NativeAddFn = Int32 Function ( Int32 a, Int32 b);
// Dart typedef: describes the Dart-side callable
typedef DartAddFn = int Function ( int a, int b);
final dylib = DynamicLibrary . open ( 'libmath.so' );
// Look up the function by symbol name
final add = dylib. lookupFunction < NativeAddFn , DartAddFn >( 'add' );
// Call it like a normal Dart function
final result = add ( 3 , 4 ); // 7
The two typedefs must agree on parameter and return types. The C typedef uses FFI native types (Int32``Double``PointerEtc.). The Dart typedef uses Dart types (int``doubleEtc.). The lookupFunction call bridges the two.
Dart and C have different primitive type systems. dart:ffi provides native types that map to C Types:
FFI Type C Type Dart Type Size (bytes) Int8int8_t / charint1 Int16int16_t / shortint2 Int32int32_t / intint4 Int64int64_t / long longint8 Uint8uint8_t / unsigned charint1 Uint16uint16_t / unsigned shortint2 Uint32uint32_t / unsigned intint4 Uint64uint64_t / unsigned long longint8 Floatfloatdouble4 Doubledoubledouble8 Boolbool (C99 _Bool)bool1 VoidvoidN/A 0 HandleDart object handle Objectplatform NativeFunction<T>function pointer N/A platform Pointer<T>T*Pointer<T>platform
VM. When passing an `Int8` value, the Dart `int` is truncated to 8 bits. When reading an `Int8` Value, it is sign-extended to 64 bits. Always be aware of the C type's range when working with FFI.Definition. Pointer<T> represents a memory address pointing to a value of type T. It is the FFI equivalent of a C pointer (T*). Pointers are opaque addresses — they have no built-in bounds Checking.
import 'package:ffi/ffi.dart' ;
// Allocate memory for a single Int32
final ptr = malloc < Int32 >();
// Allocate with calloc (zero-initialized)
final ptr2 = calloc < Int32 >();
// Allocate an array of 10 Int32 values
final array = malloc < Int32 >( 10 );
for ( var i = 0 ; i < 10 ; i ++ ) {
array[i] = i * 2 ; // array.elementAt(i).value = i * 2
The malloc and calloc functions are from package:ffiWhich provides convenience wrappers Around dart:ffi’s allocation functions.
final ptr = malloc < Int32 >( 5 );
ptr.value = 10 ; // ptr[0]
(ptr + 1 ).value = 20 ; // ptr[1]
ptr. elementAt ( 2 ).value = 30 ; // ptr[2]
print ((ptr + 1 ).value); // 20
print (ptr. elementAt ( 2 ).value); // 30
final bytePtr = ptr. cast < Uint8 >(); // reinterpret as byte array
Collector. Every `malloc` must have a corresponding `malloc.free`Or you leak native memory. Unlike Dart objects, there is no finalizer that automatically frees native memory. Use `using` from `package:ffi` or Dart's `NativeFinalizer` to ensure cleanup.import 'package:ffi/ffi.dart' ;
int addViaFfi ( int a, int b) {
// using ensures malloc.free is called when the scope exits
return using < Int32 >((alloc) {
final ptrA = alloc < Int32 >();
final ptrB = alloc < Int32 >();
return nativeAdd (ptrA, ptrB);
Definition. An FFI struct is a Dart class that extends Struct and maps to a C struct in Memory. Fields are declared using @Int32()``@Double()``@Pointer()And other annotations. The Struct layout in memory matches the C layout.
import 'package:ffi/ffi.dart' ;
class Point extends Struct {
class Rectangle extends Struct {
All struct fields must be external. The Dart compiler generates the accessor code that reads and Writes directly from/to the native memory layout.
By default, FFI structs follow the platform’s C ABI alignment rules. Use @Packed(n) to override Alignment:
// C: __attribute__((packed)) struct PackedPoint { uint8_t x; uint32_t y; };
class PackedPoint extends Struct {
@Packed(1) means no padding between fields. @Packed(2) aligns fields to 2-byte boundaries, etc.
final pointPtr = malloc < Point >();
// Pass pointer to struct to C function
nativeDrawPoint (pointPtr);
print ( 'x= ${ pointPtr . ref . x } , y= ${ pointPtr . ref . y } ' );
Use ptr.ref to access the struct fields through a pointer. The .ref property returns a proxy Object that reads/writes directly from native memory.
Callbacks allow C code to call back into Dart code. There are two mechanisms in modern Dart: NativeCallable (Dart 3.0+) and Pointer.fromFunction (legacy).
// C expects: int32_t callback(int32_t x, int32_t y)
typedef NativeCallbackFn = Int32 Function ( Int32 x, Int32 y);
typedef DartCallbackFn = int Function ( int x, int y);
// Create a native callable from a Dart function
final callback = NativeCallable < DartCallbackFn >. listener (
print ( 'Callback: $ x + $ y = ${ x + y } ' );
nativeRegisterCallback (callback.nativeFunction);
// Must close when done to prevent memory leaks
NativeCallable has two variants:
Variant Thread Safety Exception Handling Use Case NativeCallable.listenerAny thread can call Exceptions become unhandled errors Callbacks from any thread NativeCallable.isolateLocalOnly calling isolate Exceptions propagate to caller Callbacks from same isolate
Leaks native resources. The callable is valid only while the `NativeCallable` object is alive.typedef NativeCallbackFn = Int32 Function ( Int32 x, Int32 y);
typedef DartCallbackFn = int Function ( int x, int y);
final callbackPointer = Pointer . fromFunction < DartCallbackFn >(
nativeSetCallback (callbackPointer);
Code calls the callback from a different thread, the behavior is undefined and may crash. Use `NativeCallable.listener` for cross-thread callbacks.C strings (char*) and Dart strings (String) are different representations. package:ffi Provides converters.
import 'package:ffi/ffi.dart' ;
// Allocate a Utf8-encoded C string from Dart
final cString = 'Hello, C!' . toNativeUtf8 ();
nativePrintString (cString);
final cStringPtr = nativeGetString ();
// Convert to Dart String
final dartString = cStringPtr. toDartString ();
// If you own the C string, free it
// If C owns the string, do not free it
final utf16String = 'Hello' . toNativeUtf16 ();
nativeWindowsFunction (utf16String);
malloc. free (utf16String);
final result = nativeWindowsReturnString ();
final dartResult = result. toDartString ();
Conversion Direction Encoding toNativeUtf8()Dart to C UTF-8 toNativeUtf16()Dart to C UTF-16 ptr.toDartString()C to Dart UTF-8 (auto-detected length) ptr.toDartString(length: n)C to Dart UTF-8 (fixed length)
Method allocates a new Dart `String` object — it does not take ownership of the C memory. If C Allocated the string, you must free it with C's deallocator, not Dart's `malloc.free`.class Matrix4 extends Struct {
external Array < Double > values;
final matrix = malloc < Matrix4 >();
for ( var i = 0 ; i < 16 ; i ++ ) {
matrix.ref.values[i] = i. toDouble ();
print (matrix.ref.values[ 0 ]); // 0.0
C variable-length arrays (VLAs) are not directly supported. For dynamic arrays, pass a pointer and Length separately:
// C: void process(int* data, int length)
typedef NativeProcessFn = Void Function ( Pointer < Int32 > data, Int32 length);
typedef DartProcessFn = void Function ( Pointer < Int32 > data, int length);
final process = dylib. lookupFunction < NativeProcessFn , DartProcessFn >( 'process' );
final data = malloc < Int32 >( 100 );
for ( var i = 0 ; i < 100 ; i ++ ) {
The @FfiNative annotation simplifies FFI bindings by eliminating manual typedefs and lookupFunction calls. The annotation generates the binding code at compile time.
// C: int32_t add(int32_t a, int32_t b)
@FfiNative< Int32 Function ( Int32 , Int32 ) > ( 'add' )
external int add ( int a, int b);
// C: double sqrt(double x)
@FfiNative< Double Function ( Double ) > ( 'sqrt' )
external double nativeSqrt ( double x);
// C: void* malloc(size_t size)
@FfiNative< Pointer < Void > Function ( IntPtr ) > ( 'malloc' )
external Pointer < Void > nativeMalloc ( int size);
// C: void free(void* ptr)
@FfiNative< Void Function ( Pointer < Void >) > ( 'free' )
external void nativeFree ( Pointer < Void > ptr);
Than `DynamicLibrary.lookup` and produces cleaner code. It requires Dart 3.3+ and native platforms (AOT or JIT).Native C functions are synchronous and block the calling thread. In Dart’s single-threaded event Loop model, a blocking FFI call freezes the entire isolate. To avoid this, use Isolate.run to Offload FFI calls to a separate isolate.
// Synchronous FFI call that might block
int computeInC ( int input) {
return nativeHeavyComputation (input);
// Run in a separate isolate to avoid blocking the UI
Future < int > computeAsync ( int input) async {
return await Isolate . run (() => computeInC (input));
final result = await computeAsync ( 42 );
Remain responsive. Always offload potentially long-running native calls to a compute isolate via `Isolate.run`.Definition. An isolate is Dart’s unit of concurrency. Each isolate has its own memory heap, Event loop, and thread of execution. Isolates do not share memory — communication between them is Strictly via message passing through ports.
This is fundamentally different from threads in Java, C++, or Go. In those languages, threads share The same heap and require synchronization primitives (mutexes, semaphores, atomics) to prevent data Races. Dart’s isolates eliminate data races by eliminating shared state.
+------------------+ +------------------+
| Heap (private) | | Heap (private) |
| Event loop | | Event loop |
| ReceivePort | <-----> | SendPort |
| SendPort | -----> | ReceivePort |
+------------------+ +------------------+
Messages are copied between isolates. Most Dart objects can be sent (primitives, strings, lists, Maps). Objects containing unsendable types (e.g., closures, sockets, native pointers) cannot be sent Directly.
Isolate.run() is the simplest way to run computation in a separate isolate. It creates an isolate, Runs a function, sends the result back, and terminates the isolate.
Future < int > heavyComputation ( int input) async {
return await Isolate . run (() {
// This runs in a separate isolate with its own heap
for ( var i = 0 ; i < input; i ++ ) {
// main isolate is not blocked
final result = await heavyComputation ( 1000000000 );
Isolate.spawn() provides lower-level control. You explicitly create a ReceivePort for Communication and pass a SendPort to the spawned isolate.
void worker ( SendPort sendPort) {
// Can send multiple messages
final receivePort = ReceivePort ();
// Spawn isolate, passing the SendPort
await Isolate . spawn (worker, receivePort.sendPort);
final first = await receivePort.first;
// Using a stream subscription for multiple messages
final port2 = ReceivePort ();
await Isolate . spawn (worker, port2.sendPort);
await for ( final message in port2) {
For long-running isolates that need ongoing communication:
final ReceivePort _mainReceivePort = ReceivePort ();
late SendPort _workerSendPort;
Future < void > start () async {
final workerReceivePort = ReceivePort ();
_isolate = await Isolate . spawn (
_MainMessage (mainPort : workerReceivePort.sendPort),
// Wait for worker to send its SendPort
final completer = Completer < SendPort >();
workerReceivePort. listen ((message) {
if (message is SendPort ) {
completer. complete (message);
_workerSendPort = await completer.future;
Future < int > compute ( int value) async {
final responsePort = ReceivePort ();
_workerSendPort. send ( _Request (value : value, replyPort : responsePort.sendPort));
return await responsePort.first as int ;
_isolate ? . kill (priority : Isolate .immediate);
_mainReceivePort. close ();
void _workerLoop ( _MainMessage message) {
final receivePort = ReceivePort ();
message.mainPort. send (receivePort.sendPort);
receivePort. listen ((msg) {
final result = msg.value * 2 ;
msg.replyPort. send (result);
_MainMessage ({ required this .mainPort});
final SendPort replyPort;
_Request ({ required this .value, required this .replyPort});
Receive them in that order. However, messages sent from different isolates to the same port may be Interleaved — there is no global ordering guarantee across multiple senders.Dart’s event loop processes two types of tasks:
Microtasks . High-priority tasks scheduled with scheduleMicrotask or via Future.then(). Microtasks run to completion before the event loop processes the next event. They are used for internal async plumbing.
Events (macrotasks) . I/O events, timers, UI events, isolate messages, etc. The event loop processes one event at a time, running all microtasks queued during event processing before moving to the next event.
// Process all queued microtasks
while (microtaskQueue.isNotEmpty) {
final task = microtaskQueue.removeFirst();
if (eventQueue.isNotEmpty) {
final event = eventQueue.removeFirst();
scheduleMicrotask (() => print ( 'microtask 1' ));
scheduleMicrotask (() => print ( 'microtask 2' ));
Future . microtask (() => print ( 'microtask 3' ));
Future (() => print ( 'event 1' )); // schedules as a Timer event
A Completer<T> is the manual control interface for a Future. Normally, Future values are Created by async/await or Future.value/Future.error. A Completer gives you explicit Control over when the future completes.
final completer = Completer < String >();
// Somewhere else in the code:
void onResultReceived ( String result) {
if ( ! completer.isCompleted) {
completer. complete (result);
void onError ( Exception e) {
if ( ! completer.isCompleted) {
completer. completeError (e);
// Consumers await the future
final result = await completer.future;
Common use cases for Completer:
Bridging callback-based APIs to Future-based APIs. Implementing request/response patterns over message ports. Coordinating multiple async operations with custom logic. // Bridge a callback-based API to Future
Future < HttpResponse > makeRequest ( HttpClient client, String url) {
final completer = Completer < HttpResponse >();
client. getUrl ( Uri . parse (url)). then ((request) {
request. close (). then ((response) {
if ( ! completer.isCompleted) {
completer. complete (response);
if ( ! completer.isCompleted) {
completer. completeError (e);
if ( ! completer.isCompleted) {
completer. completeError (e);
Dart can compile to WebAssembly (WASM) via dart compile wasm. This produces a .wasm file plus a JS bootstrap file that loads and runs it in the browser. WASM compilation is an alternative to dart2js (which compiles to JavaScript).
dart compile wasm bin/app.dart
## Output: app.wasm, app.mjs (JS module bootstrap)
Characteristic dart2js dart2wasm Output format JavaScript WebAssembly + JS bootstrap Execution speed V8 JIT on JS Near-native via WASM Startup time Slower (JS parse + JIT) Faster (WASM is compact) Code size Larger Smaller Debugging Source maps DWARF debug info JS interop Direct Via package:web Browser support All WASM GC required (Chrome 119+, Firefox 120+)
import 'package:web/web.dart' as web;
final document = web.document;
final div = document. createElement ( 'div' );
div.textContent = 'Hello from Dart WASM' ;
document.body ? . appendChild (div);
final response = await web.window. fetch ( 'https://api.example.com/data' );
final data = await response. json ();
package:web provides typed bindings for all Web APIs (DOM, Fetch, WebSocket, etc.). These bindings Use Dart’s JS interop types (JSObject``JSString``JSArrayEtc.) rather than Dart’s native Types.
WASM compilation produces code that runs at near-native speed for compute-heavy workloads. The WASM Binary is compact and loads quickly. However, every call from WASM to JavaScript (and vice versa) Has overhead. For DOM-heavy applications, this overhead can dominate. For compute-heavy applications (e.g., image processing, cryptography, simulations), the performance gain is significant.
Is available in Chrome 119+, Firefox 120+, and Safari 17.4+. Older browsers fall back to dart2js.Definition. Extension types (introduced in Dart 3) allow you to define a compile-time wrapper Around an existing type. The wrapper has zero runtime overhead — it is erased at compile time. The Extension type’s methods are resolved statically and dispatch to the underlying representation’s API.
extension type UserId ( int value) {
bool get isValid => value > 0 ;
String get formatted => 'USR- $ value ' ;
extension type EmailAddress ( String value) {
bool get isValid => value. contains ( '@' );
String get domain => value. split ( '@' ).last;
// Usage — these are distinct types at compile time
void processUser ( UserId id, EmailAddress email) {
print ( 'User ${ id . formatted } , domain: ${ email . domain } ' );
// Type safety at zero runtime cost
EmailAddress email = EmailAddress ( 'user@example.com' );
// processUser(id, id); // Compile error: UserId is not EmailAddress
Extension types are erased to their representation type at runtime. UserId becomes intAnd EmailAddress becomes String. There is no wrapper object allocation, no indirection, and no Virtual dispatch. This is Dart’s answer to the “newtype” pattern from Haskell or the “phantom type” Pattern from TypeScript.
Extension methods add functionality to existing types without subclassing or modifying the original Class.
extension StringExtension on String {
String get capitalized =>
isEmpty ? '' : " ${ this [ 0 ]. toUpperCase ()}${ substring ( 1 )} '';
bool get isNumeric => double.tryParse(this) != null;
String repeat(int times) => this * times;
extension ListExtension<T> on List<T> {
List<T> sortedBy<K extends Comparable>(K Function(T) key) {
copy.sort((a, b) => key(a).compareTo(key(b)));
" hello world '.capitalized; // ' Hello world '
' 123.45 '.isNumeric; // true
' abc '.repeat(3); // ' abcabcabc '
final users = [{' name ': "Bob''}, {"name' : "Alice''}];
users.sortedBy((u) => u[" name '] as String);
Extension methods are resolved statically. They do not modify the underlying type and cannot Override existing methods. If an extension method conflicts with a method on the type itself, the Type’s method wins.
Definition. Sealed classes restrict which classes can extend or implement them. The set of Subtypes is known at compile time and must be defined in the same library. This enables exhaustive Pattern matching — the compiler knows all possible subtypes and can verify that all cases are Handled.
class IntLiteral extends Expr {
class AddExpr extends Expr {
AddExpr ( this .left, this .right);
class MulExpr extends Expr {
MulExpr ( this .left, this .right);
// Exhaustive — compiler verifies all subtypes are handled
int eval ( Expr expr) => switch (expr) {
IntLiteral ( :final value) => value,
AddExpr ( :final left, :final right) => eval (left) + eval (right),
MulExpr ( :final left, :final right) => eval (left) * eval (right),
// No default needed — sealed ensures exhaustiveness
The key constraint: all direct subtypes of a sealed class must be in the same library (same package File or part file). Sub-subtypes can be in other libraries, but the direct subtypes that form the Exhaustive set must be co-located.
Records are anonymous, immutable, fixed-size collections of named and positional fields.
final point = ( 1.0 , 2.0 );
final user = (name : "Alice'', age: 30);
print(user.name); // " Alice '
final entry = (' key ', value: 42);
print(entry. $ 1 ); // ' key '
print(entry.value); // 42
// Record type annotations
(int, String) pair = (1, ' hello ');
({int x, int y}) coords = (x: 10, y: 20);
(int, {String name}) mixed = (42, name: "test'');
// Records in function signatures
({String name, int age}) fetchUser(int id) {
return (name: "Alice' , age : 30 );
final ( : name, : age) = (name : "Bob'', age: 25);
Records are value types. Two records with the same fields are equal:
print (( 1 , "a') == (1, 'a')); // true
print((1, 'a') == (1, 'b')); // false
Records are useful as lightweight return types that replace the need for dedicated classes when you Need to return multiple values:
// Before: dedicated class
DivisionResult ( this .quotient, this .remainder);
( int quotient, int remainder) divide ( int a, int b) {
Dart 3 introduced comprehensive pattern matching with switch expressions, if-case, guards, and Destructuring.
String describeNumber ( int n) => switch (n) {
String describeType ( Object value) => switch (value) {
class Circle extends Shape {
class Rectangle extends Shape {
Rectangle ( this .width, this .height);
String describe ( Shape shape) => switch (shape) {
Circle ( :final radius) when radius > 10 => 'large circle (r= $ radius )' ,
Circle ( :final radius) => 'circle (r= $ radius )' ,
Rectangle ( :final width, :final height) => 'rect ${ width } x $ height ' ,
void processEntry (( String , int ) entry) {
case ( final key, final value) when value > 100 :
print ( 'Large: $ key = $ value ' );
case ( final key, final value) :
print ( 'Small: $ key = $ value ' );
void process ( Object value) {
if (value case int x when x > 0 ) {
print ( 'positive integer: $ x ' );
} else if (value case String s) {
print ( 'unknown: $ value ' );
Guards are when clauses that add conditions to pattern matches:
String classify ( int value) => switch (value) {
_ when value % 15 == 0 => 'fizzbuzz' ,
_ when value % 3 == 0 => 'fizz' ,
_ when value % 5 == 0 => 'buzz' ,
bool isSmallOrLarge ( int n) => switch (n) {
bool isNotString ( Object value) => switch (value) {
Dart’s power tools: FFI lets Dart talk to C libraries, isolates run tasks in parallel, and mirrors enable dynamic code generation. These are advanced tools for specific needs.
Why it matters: FFI enables integration with existing C libraries, isolates improve performance, and mirrors enable dynamic programming.
The key insight: FFI is for performance-critical code or existing C libraries — prefer pure Dart for portability.
The most common FFI bug is forgetting to free native memory. Dart’s garbage collector does not track Native allocations. Every malloc``callocAnd toNativeUtf8 must have a corresponding free.
// LEAK: native string never freed
final cStr = 'hello' . toNativeUtf8 ();
// Missing: malloc.free(cStr)
final cStr = 'hello' . toNativeUtf8 ();
void correctStringUsing () {
final cStr = 'hello' . toNativeUtf8 (allocator : alloc);
// Arena frees all allocations when scope exits
Using a pointer after the underlying memory has been freed causes undefined behavior. The Dart VM Does not detect use-after-free.
// DANGEROUS: pointer used after free
final ptr = malloc < Int32 >();
print (ptr.value); // Undefined behavior — memory may have been reused
// DANGEROUS: pointer outlives the isolate that allocated it
Pointer < Int32 > globalPtr;
globalPtr = malloc < Int32 >();
// If the allocating isolate is killed, globalPtr becomes dangling
// CRASH: accessing late field before initialization
final service = Service ();
service.client. get ( Uri . parse ( 'https://example.com' ));
// LateInitializationError: Field 'client' has not been initialized
// SAFE: initialize in constructor
late final HttpClient client;
// UNNECESSARY: flow analysis already promotes
// CORRECT: trust flow analysis
print (name.length); // promoted to String, no ! needed
// CORRECT: use ?? for defaults
print ((name ?? '' ).length);
Messages sent from a single SendPort to a single ReceivePort are delivered in order. But Messages sent from different SendPortS to the same ReceivePort may interleave.
// Isolate A sends: M1, M2, M3
// Isolate B sends: N1, N2, N3
// Receiver might get: M1, N1, M2, N2, M3, N3
// But always: M1 before M2 before M3, and N1 before N2 before N3
If you need ordering guarantees across multiple senders, implement a sequence number or use a single Sender isolate as a coordinator.
// ANTI-PATTERN: adding ? everywhere instead of thinking about nullability
String ? name; // Should this really be nullable?
int ? age; // Does age ever need to be null?
String ? email; // Or should it be required?
// BETTER: make fields non-nullable with proper initialization
// ANTI-PATTERN: wrapping everything in try-catch to handle null
// BETTER: use null-coalescing
String getName () => _name ?? 'unknown' ;
// WRONG: using int (Dart 64-bit) where C expects int32_t
// If the C function returns -1 as int32_t, it becomes 4294967295 as uint64_t
typedef NativeFn = Int32 Function ();
typedef DartFn = int Function ();
// When the C function returns Int32 with the high bit set,
// Dart receives a sign-extended int. If you cast to Uint32:
int result = fn (); // result is -1 (sign-extended)
int unsigned = result & 0xFFFFFFFF ; // 4294967295
// Always match the C type width exactly:
// C int32_t -> Dart Int32 typedef
// C uint32_t -> Dart Uint32 typedef
// C size_t -> Dart IntPtr typedef (platform-dependent width)
// BUG: sending non-sendable objects
void spawnWorker () async {
final receivePort = ReceivePort ();
final httpClient = HttpClient (); // HttpClient is not sendable
// This throws at runtime: HttpClient cannot be sent between isolates
Isolate . spawn (worker, (receivePort.sendPort, httpClient));
// FIX: create resources inside the worker isolate
void spawnWorker () async {
final receivePort = ReceivePort ();
Isolate . spawn (worker, receivePort.sendPort);
void worker ( SendPort sendPort) {
// Create HttpClient inside the isolate
final httpClient = HttpClient ();
Not all Dart objects can be sent between isolates. Sendable objects include: primitives, String List and Map of sendable elements, SendPort``TransferableTypedDataAnd Capability. Non- Sendable objects include: closures, Socket``HttpClient``File``IsolateAnd most dart:io Types.
This topic covers the core concepts of ffi and advanced topics, including underlying theory, practical implementation, and key applications.
Key concepts include:
core concepts and terminology algorithms and computational thinking practical implementation security and ethical considerations applications in the real world Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Problem. Use dart:ffi to call the C standard library strlen function from Dart.
Solution.
import 'package:ffi/ffi.dart' ;
typedef NativeStrlen = Uint32 Function ( Pointer < Utf8 > s);
typedef DartStrlen = int Function ( Pointer < Utf8 > s);
final dylib = DynamicLibrary . process ();
final strlenPtr = dylib. lookup < NativeFunction < NativeStrlen >>( 'strlen' );
final strlen = strlenPtr. asFunction < DartStrlen >();
final cstr = s. toNativeUtf8 ();
Key points: lookup finds the symbol in the dynamic library. asFunction converts the native function pointer to a Dart callable. Memory allocated with toNativeUtf8() must be freed manually.
■ \blacksquare ■
Problem. A controller is initialised asynchronously. Show how to use late safely with a fallback.
Solution.
Future < void > init () async {
db = await Database . connect ( 'path.db' );
Future < List < User >> getUsers () async {
return db. query ( 'SELECT * FROM users' );
The late keyword tells the compiler that db will be initialised before first use. The guard check in getUsers ensures the database is connected before querying. Using late final enforces single assignment.
■ \blacksquare ■
Dart FFI (dart:ffi) bridges Dart code to C libraries: DynamicLibrary.lookup finds symbols, asFunction wraps them. Native types map to Dart types: Int32 → \to → int, Double → \to → double, Pointer<T> → \to → Pointer<T>. Null safety is sound: late defers initialisation, ? marks nullable types, ! asserts non-null. Isolates provide concurrency without shared memory; Compute simplifies single-use isolate spawning. dart:ffi requires manual memory management: always free allocated native memory in a finally block. Dart Classes and Objects — Null safety and type system features build on the class fundamentals covered in OOP.Dart Asynchronous Programming — Isolates and async patterns are both approaches to concurrent programming in Dart.Rust Ownership — Rust’s ownership model provides memory safety without garbage collection, contrasting with Dart’s approach.Java GraalVM — Both GraalVM native images and Dart FFI address native code interoperability.