It was just a regular day of coding until I wondered: “How does a language like Dart manage to provide a single behavior for such a large number of target platforms?”. If you think about it that way, then you can compile your Dart code into binaries, executables, snapshots, interpret the code in JavaScript or WebAssembly, and eventually get the same behavior everywhere. “Is it true? There must be a lot of developer work behind this…” I thought and decided to figure it out.

As an object of research, I decided to consider the work of built-in types.

The cat looks under the hood

How do the built-in types work?

Built-in types are bool, int, String and others that we find in every programming language. I will not stop to explain each of them, for that you can refer to this documentation.

The general interface of these types are described in dart:core. This is one of the internal libraries of the SDK. It is automatically imported into each file.

However, if you pay attention to the class headers inside, you will notice that they only provide type signatures, but do not implement them.

abstract final class int extends num {
  external const factory int.fromEnvironment(String name,
      {int defaultValue = 0});

  int operator &(int other);

  int modPow(int exponent, int modulus);
  // ...
}

sdk/lib/core/int.dart - Int type abstraction

abstract final class String implements Comparable<String>, Pattern {
  external factory String.fromCharCodes(Iterable<int> charCodes,
      [int start = 0, int? end]);

  String operator [](int index);

  int codeUnitAt(int index);
  // ...
}

sdk/lib/core/string.dart - String type abstraction

This is done because the implementation of these types depends on the platform and its variant on which it will be compiled.

When compiling your code to native (self-contained executables, JIT modules), then under the hood you will use the Dart virtual machine (Dart VM), but when building in the web, we will use the JavaScript interpretation instead. How do we specify the necessary implementation of our types without considering the nuances of the platform? For this purpose, there are patches available in the Dart language.

Here’s how patches work:

  • There is a class that defines the interface for a type. For example, let’s look at Null:

    /// The reserved word `null` denotes an object that is the sole instance of
    /// this class.
    @pragma("vm:entry-point")
    final class Null {
      factory Null._uninstantiable() {
        throw UnsupportedError('class Null cannot be instantiated');
      }
    
      external int get hashCode;
    
      /// Returns the string `"null"`.
      String toString() => "null";
    }
    

    sdk/lib/core/null.dart

    Those getters and constructors that will need a platform implementation are marked as external. This is how we will inform the compiler that the implementation of these things is located elsewhere. In our case, we will denote the hashCode as external for all patches for Null.

  • Here are patches for this class: files that implement the necessary implementation depending on the platform. You can find patches for Null inside the SDK in the following directories:

    Let’s take a look at one of them:

    // Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
    // for details. All rights reserved. Use of this source code is governed by a
    // BSD-style license that can be found in the LICENSE file.
    
    import "dart:_internal" show patch;
    
    @patch // <--
    @pragma('vm:deeply-immutable')
    @pragma("vm:entry-point")
    class Null {
      static const _HASH_CODE = 2011; // The year Dart was announced and a prime.
    
      @patch // <--
      int get hashCode => _HASH_CODE;
    
      int get _identityHashCode => _HASH_CODE;
    }
    

    sdk/lib/_internal/vm_shared/lib/null_patch.dart - patch for Dart VM.

    A class with a platform implementation is a class of the same name as the main one, which is marked with the @patch annotation. These classes provide platform implementations. These implementations are also marked with the @patch annotation.

    💡 Patches cannot change the signature of methods or functions, only their implementation.

    The patch annotation is declared as follows:

    part of dart._internal;
    
    class _Patch {
      const _Patch();
    }
    
    const _Patch patch = const _Patch();
    

    sdk/lib/internal/patch.dart - patch annotation.

    It is available in dart:_internal, an internal SDK package that is not available to the outside world. During the compilation process, we pass the path to the necessary patches to the compiler, which it combines with the main code.

    if (uri.isScheme("dart")) {
      target.readPatchFiles(libraryBuilder);
    }
    

    pkg/front_end/lib/src/source/source_loader.dart:540-542 - _createSourceCompilationUnit

    void readPatchFiles(SourceLibraryBuilder libraryBuilder) {
      assert(libraryBuilder.importUri.isScheme("dart"));
      List<Uri>? patches =
          uriTranslator.getDartPatches(libraryBuilder.importUri.path);
      if (patches != null) {
        for (Uri patch in patches) {
          libraryBuilder.loader.read(patch, -1,
              fileUri: patch,
              origin: libraryBuilder,
              accessor: libraryBuilder.compilationUnit,
              isPatch: true);
        }
      }
    }
    

    pkg/front_end/lib/src/kernel/kernel_target.dart:1811-1824 - readPatchFiles

  • In order for the language to identify necessary patches, these patches need to be described in the libraries.yaml file inside SDK (and the libraries.json file which is generated from it). For example, this is how patches for the dart:core library, which is used to build code with VM, declared:

    vm_common:
      libraries:
        core:
          uri: "core/core.dart"
          patches:
            - "_internal/vm/lib/core_patch.dart"
            - "_internal/vm_shared/lib/array_patch.dart"
            - "_internal/vm_shared/lib/bigint_patch.dart"
            - "_internal/vm_shared/lib/bool_patch.dart"
            - "_internal/vm_shared/lib/date_patch.dart"
            - "_internal/vm_shared/lib/integers_patch.dart"
            - "_internal/vm_shared/lib/map_patch.dart"
            - "_internal/vm_shared/lib/null_patch.dart"
            - "_internal/vm_shared/lib/string_buffer_patch.dart"
    
    vm:
      include:
        - target: "vm_common"
      libraries:
        cli:
          uri: "cli/cli.dart"
    

    sdk/lib/libraries.yaml

    The libraries.json is the basis (not counting the analyzer) for specifying packages and patches for each target platform. When running dart run or dart compile, the platform accesses it to find the necessary information and then collects the “patched” code automatically in build.

    💡 When compiling your code, you can get information about an internal package that has been included. This is done using bool.fromEnvironment('dart.library.name') where name is the package name. This is how global web constants are obtained:

     const bool kIsWeb = bool.fromEnvironment('dart.library.js_util');
     const bool kIsWasm = kIsWeb && bool.fromEnvironment('dart.library.ffi');
    
  • Finally, during the build to a target platform, the built-in types are assembled exclusively with their implementation for that platform 🙂.

Built-in types on platforms: Web

An important detail to keep in mind, if we are talking about web, is that Dart interprets code in JavaScript or WebAssembly. Using built-in types means using the types of these languages. And in many ways they match, but there are a couple of limitations.

Dart type JavaScript type WebAssembly type
int number i32, i64
double number f32, f64
String string i8 array, i16 array
bool boolean i32

When compiling to JavaScript, integers are restricted to values that can be represented exactly by double-precision floating point values. The available integer values include all integers between -(2^53) and 2^53, and some integers with larger magnitude. That includes some integers larger than 2^63. The behavior of the operators and methods in the int class therefore sometimes differs between the Dart VM and Dart code compiled to JavaScript. For example, the bitwise operators truncate their operands to 32-bit integers when compiled to JavaScript.

int a = 0xFFFFFFFF + 3;
print(a); // Dart VM: 4294967295
print(a); // JavaScript: 4294967298

int c = a >> 2;
print(c); // Dart VM: 1073741823
print(c); // JavaScript: 0

WebAssembly has also received special attention because it can invoke JavaScript to execute code. That’s why in libraries.json you can see wasm, wasm_js_compatibility and wasm_common targets. The latter is used to specify common patches.

The JS compatibility and static next-generation JS interop allows you to access the browser’s API from Dart code compiled to WebAssembly (package:web).

Built-in types on platforms: Dart VM

When compiling the code for Dart VM, note that each built-in type described in Dart is represented in C++. For example, this is how the representation of the bool type looks like:

// Class Bool implements Dart core class bool.
class Bool : public Instance {
 public:
  bool value() const { return untag()->value_; }

  static intptr_t InstanceSize() {
    return RoundedAllocationSize(sizeof(UntaggedBool));
  }

  static const Bool& True() { return Object::bool_true(); }

  static const Bool& False() { return Object::bool_false(); }

  static const Bool& Get(bool value) {
    return value ? Bool::True() : Bool::False();
  }

  virtual uint32_t CanonicalizeHash() const {
    return ptr() == True().ptr() ? kTrueIdentityHash : kFalseIdentityHash;
  }

 private:
  FINAL_HEAP_OBJECT_IMPLEMENTATION(Bool, Instance);
  friend class Class;
  friend class Object;  // To initialize the true and false values.
};

runtime/vm/object.h::Bool

Most of the values will be stored in the garbage-collected heap. Thanks to this, Dart developers do not have to worry about manually allocating and freeing memory.

Values such as true, false, and null will be considered immutable and will be automatically added to heap. This is done so that subsequent uses of bools do not take up more memory, but access already existing values. They are all allocated in the non-GC’d Dart::vm_isolate_.

// Allocate and initialize the null instance.
// 'null_' must be the first object allocated as it is used in allocation to
// clear the pointer fields of objects.
{
  uword address =
      heap->Allocate(thread, Instance::InstanceSize(), Heap::kOld);
  null_ = static_cast<InstancePtr>(address + kHeapObjectTag);
  InitializeObjectVariant<Instance>(address, kNullCid);
  null_->untag()->SetCanonical();
}
// ...
{
  // Allocate true.
  uword address = heap->Allocate(thread, Bool::InstanceSize(), Heap::kOld);
  true_ = static_cast<BoolPtr>(address + kHeapObjectTag);
  InitializeObject<Bool>(address);
  true_->untag()->value_ = true;
  true_->untag()->SetCanonical();
}
{
  // Allocate false.
  uword address = heap->Allocate(thread, Bool::InstanceSize(), Heap::kOld);
  false_ = static_cast<BoolPtr>(address + kHeapObjectTag);
  InitializeObject<Bool>(address);
  false_->untag()->value_ = false;
  false_->untag()->SetCanonical();
}

runtime/vm/object.cc::InitNullAndBool

By the way, in patches for a virtual machine, you will often see a special pragma "vm:external-name". The implementation of such methods can be found in the code of the VM.

@patch
@pragma('vm:deeply-immutable')
@pragma("vm:entry-point")
class bool {
  @patch
  @pragma("vm:external-name", "Bool_fromEnvironment")
  external const factory bool.fromEnvironment(String name,
      {bool defaultValue = false});
  // ...
}

sdk/lib/_internal/vm_shared/lib/bool_patch.dart

DEFINE_NATIVE_ENTRY(Bool_fromEnvironment, 0, 3) {
  GET_NON_NULL_NATIVE_ARGUMENT(String, name, arguments->NativeArgAt(1));
  GET_NATIVE_ARGUMENT(Bool, default_value, arguments->NativeArgAt(2));
  // Call the embedder to supply us with the environment.
  const String& env_value =
      String::Handle(Api::GetEnvironmentValue(thread, name));
  if (!env_value.IsNull()) {
    if (Symbols::True().Equals(env_value)) {
      return Bool::True().ptr();
    }
    if (Symbols::False().Equals(env_value)) {
      return Bool::False().ptr();
    }
  }
  return default_value.ptr();
}

runtime/lib/bool.cc

💡 You can read more about the pragmas for VM here.

Numbers

Int type has two variations: small integer (Smi) and middle integer (Mint).

Small integer value range is from -(2^N) to 2^N-1 where N = 30 (32-bit build) or N = 62 (64-bit build). Dart uses Smi to represent some internal properties of objects, such as the length of lists.

Middle integer value range is from -2^63 to (2^63)-1.

When you write a number into a variable or interact with it, Dart selects a type of that number. If you go beyond the Smi size, it automatically converts the type to Mint.

💡 (2^63)-1 = 9,223,372,036,854,775,807

If the dimension of this number is not enough for you, you can use the built-in complex BigInt type. The number in BigInt is represented internally by a sign, an array of 32-bit unsigned integers in little-endian format, and a number of used digits in that array.

BigInt maxInt = BigInt.from(9223372036854775807);
BigInt moreThenInt = maxInt + BigInt.parse('12098679128739182365102983');
print(moreThenInt); // 12098688352111219219878790

Strings

Strings in Dart are immutable, meaning that their contents cannot be changed after they have been created. Any additions or modifications to a string in your code result in the creation of a new string.

The strings have variations: OneByteString and TwoByteString.

The content of the string plays a role in choosing the implementation. If each character in the string is Latin and has a code unit from 0 to 255, then a OneByteString will be created because one byte can be allocated for each character of the string for storage. In all other cases, TwoByteString is used.

If the characters in the string are more than two bytes (this can happen within UTF-32) it is decomposed into a surrogate pair.

final clef = String.fromCharCodes([0x1D11E]);
clef.codeUnitAt(0); // 0xD834
clef.codeUnitAt(1); // 0xDD1E

Strings also have size restrictions, these are MaxSmi / 2 = 2305843009213693951.

Nulls and hashCodes

An interesting fact about true, false and null : their hash codes are predefined only in Dart VM.

// Matches null_patch.dart / bool_patch.dart.
static constexpr intptr_t kNullIdentityHash = 2011;
static constexpr intptr_t kTrueIdentityHash = 1231;
static constexpr intptr_t kFalseIdentityHash = 1237;

runtime/vm/object.h

In patches for the web, the hashCode is defined by the Object.hashCode.

@patch
class Null {
  @patch
  int get hashCode => super.hashCode;
}

sdk/lib/_internal/js_runtime/lib/core_patch.dart

Therefore, the hashСode values will be randomly generated, but will be strictly defined in the native code. However, this will not have any impact on the functionality of your code.

Finally

We have studied how the built-in types are implemented in Dart, including their location and structure. We also looked at the differences and limitations that arise when working with them in different implementations. We saw how the patch system mechanism works and understand which libraries are available on the platform.

If you want to continue studying in this direction, I recommend the following links:

Also note that the patch system is used not only for built-in types, but also for asynchronous mechanism and event loop, work with isolates and so on.

Many thanks to Manojlović Melanija for help in writing the article.