Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Dart (3.13)  Module 15 – Core libraries, files and HTTP

JSON, UTF-8 and base64

Encode and decode JSON in Dart, see what decoding does to types and large integers, write a typed model by hand, and convert text to bytes and back.

  • Intermediate
  • 15 minutes
  • Examples run with dart 3.13.5 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: What jsonEncode and jsonDecode really hand you · What happens to a number on the way through · Practise with a body you do not trust. With a pass: 2 in-depth parts and 3 interview answers, about 10 minutes.

What you will learn

  • Encode a nested object with toJson and pretty-print it with JsonEncoder.withIndent
  • Predict the Dart type of every value jsonDecode returns for a given body
  • Write fromJson and toJson by hand so a bad body is refused with the field that failed
  • Convert between text and bytes with UTF-8 and base64, including a URL-safe alphabet
  • Decode a byte stream with converters so a character split across two reads survives

Before you start

What you’ll learn · Before you start

What you will learn

  • Encode a nested object with toJson and pretty-print it with JsonEncoder.withIndent
  • Predict the Dart type of every value jsonDecode returns for a given body
  • Write fromJson and toJson by hand so a bad body is refused with the field that failed
  • Convert between text and bytes with UTF-8 and base64, including a URL-safe alphabet
  • Decode a byte stream with converters so a character split across two reads survives

Before you start

On this page

An order with the id 9007199254740993 goes out as JSON and comes back as 9007199254740992 — one less, silently, with no error anywhere. The same program, the same library, two compilation targets.

dart:convert is two functions and a handful of codecs; the care goes into what they hand you. You will encode a nested order, read the types that come back, measure that big-integer difference between the virtual machine and a JavaScript build, then write a model by hand that refuses a bad body with the field that failed, and convert bytes with UTF-8 and base64 well enough that a character split across two reads still arrives whole.

What jsonEncode and jsonDecode really hand you

serialisation: Turning a value in memory into a form that can be stored or sent, such as JSON text, and back again; in Dart the outward step is usually a toJson method. starts with a method, not a library call. jsonEncode knows maps, lists, strings, numbers, booleans and null; for anything else it calls toJson() and encodes whatever that returns. The run below encodes an order of two items, prints it indented, and then reads it back.

An order out to text, and the types that come back Dart · round_trip.dart
import 'dart:convert';

class Item {
  Item(this.sku, this.qty);
  final String sku;
  final int qty;
  Map<String, Object?> toJson() => {'sku': sku, 'qty': qty};
}

class Order {
  Order(this.id, this.items);
  final String id;
  final List<Item> items;
  Map<String, Object?> toJson() => {'id': id, 'items': items};
}

void main() {
  final order = Order('A-4181', [Item('tea-250g', 2), Item('jaggery-1kg', 1)]);

  final compact = jsonEncode(order);
  print('compact: $compact');
  print('characters of the text: ${compact.length}');

  print('indented:');
  print(const JsonEncoder.withIndent('  ').convert(order));

  final back = jsonDecode(compact);
  print('decoded type: ${back.runtimeType}');
  final map = back as Map<String, Object?>;
  print('id: ${map['id']}  items is: ${map['items'].runtimeType}');
  final items = map['items']! as List<Object?>;
  print('first item: ${items.first}  type: ${items.first.runtimeType}');
  print('qty read without a check: ${(items.first as Map<String, Object?>)['qty']}');
}

Output

compact: {"id":"A-4181","items":[{"sku":"tea-250g","qty":2},{"sku":"jaggery-1kg","qty":1}]}
characters of the text: 82
indented:
{
  "id": "A-4181",
  "items": [
    {
      "sku": "tea-250g",
      "qty": 2
    },
    {
      "sku": "jaggery-1kg",
      "qty": 1
    }
  ]
}
decoded type: _Map<String, dynamic>
id: A-4181  items is: List<dynamic>
first item: {sku: tea-250g, qty: 2}  type: _Map<String, dynamic>
qty read without a check: 2

Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run round_trip.dart

Two things in that output decide how the rest of your code looks. The key order is the order your map wrote, because JSON objects have no order of their own, so a comparison with another system’s output compares parsed values rather than strings. And the decoded value is a _Map<String, dynamic> holding a List<dynamic>: jsonDecode returns Object?, so items.first['qty'] is an unchecked claim about a body a stranger sent.

Interactive · stage by stage One order out to text and back again Follow one order through encoding and decoding, and see where the types are lost.
One order out to text and back again, step 6 of 6. Showing an Order object, toJson(), jsonEncode text, jsonDecode gives, a checked Order. In focus: a checked Order.cast onlyan Order objecttyped fieldstoJson()a Map of valuesjsonEncode text82 charactersjsonDecode givesMap of dynamica checked Ordertyped again

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A typed value. The program holds an Order with a String id and a List of Items. Every field has a type the compiler knows, and nothing about the value can be wrong in a way the type system allows.
  2. toJson is the hook. jsonEncode does not know your class, so it calls toJson() on it and expects a value made of maps, lists, strings, numbers, booleans and null. The method is where your field names become keys.
  3. Text, and only text. What goes over the wire is 82 characters of JSON. The order of the keys is the order your map wrote them; nothing else of the class survives, not the class name and not the field types.
  4. Decoding returns dynamic. jsonDecode hands back Object?, which for this text is a Map of String to dynamic holding a List of dynamic. The compiler now knows nothing, so every read is a claim that has not been checked yet.
  5. Two ways onwards. Follow the branch and cast your way through the map: short to write, and every bad body becomes a cast error somewhere in your code. Or build the value back with a constructor that checks each field and says which one failed.
  6. Typed again, on purpose. A checked model gives the rest of the program the same guarantees it had before encoding, and gives whoever sent a bad body a message naming the field rather than a stack trace.

JsonEncoder.withIndent(' ') is the same encoder with a two-space indent, useful for a file a person reads or a fixture you want to diff. For a long response you have already received, our JSON formatter does the same job in the browser.

Key idea

Encoding is a method you write; decoding is a value you must check. toJson() decides the shape that leaves your program, and everything that comes back is Object? until your own code proves otherwise.

Checkpoint

Check: What encoding and decoding hand you

  1. Question 1 of 2 You pass an object of your own class to jsonEncode and it is not a map, a list, a string, a number, a bool or null. What does jsonEncode do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It calls toJson() on the object and encodes what that returns

    jsonEncode handles the built-in types and delegates everything else to toJson(), so an object without that method raises a JsonUnsupportedObjectError.

  2. Question 2 of 2 The run decodes the order text. What is the static type of what jsonDecode returned, and why does it matter?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Object?, so every field read afterwards is unchecked until your code proves the shape

    The run prints the runtime type as a map of String to dynamic, but the static type is Object?: the two together are why a cast chain compiles and then throws deep inside the program.

What happens to a number on the way through

JSON has one number type and Dart has two, so decoding has to choose. The rule is the text: a body with no decimal point and no exponent becomes an int, anything else a double.

Six bodies, and the Dart type each one decodes to Dart · number_typing.dart
import 'dart:convert';

void main() {
  for (final text in ['1', '1.0', '1e3', '2500', '0.1', '9007199254740993']) {
    final value = jsonDecode(text);
    print('jsonDecode("$text") -> $value  (${value.runtimeType})');
  }

  final body = jsonDecode('{"amount_paise": 49900, "rate": 1.0}') as Map<String, Object?>;
  print('amount_paise is int: ${body['amount_paise'] is int}');
  print('rate is int: ${body['rate'] is int}  rate is double: ${body['rate'] is double}');
  print('both are num: ${body.values.every((v) => v is num)}');

  final asNum = body['rate']! as num;
  print('read as num, used as double: ${asNum.toDouble()}');
  print('re-encoded: ${jsonEncode(body)}');
}

Output

jsonDecode("1") -> 1  (int)
jsonDecode("1.0") -> 1.0  (double)
jsonDecode("1e3") -> 1000.0  (double)
jsonDecode("2500") -> 2500  (int)
jsonDecode("0.1") -> 0.1  (double)
jsonDecode("9007199254740993") -> 9007199254740993  (int)
amount_paise is int: true
rate is int: false  rate is double: true
both are num: true
read as num, used as double: 1.0
re-encoded: {"amount_paise":49900,"rate":1.0}

Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run number_typing.dart

That is why a field you read as int throws the day the sender starts writing 1.0, and why value as num with toDouble() is the tolerant way to read a quantity that may be whole. The run also re-encodes the map and shows 1.0 surviving as 1.0: double keeps its decimal point on the way out, so a round trip does not quietly turn money into an integer.

The second measurement is the one in the first line of this lesson. The same program, run on the virtual machine and then compiled to JavaScript and run by Node.js, disagrees about a large integer.

The same decoding on the VM and in a JavaScript build Python · web_session.py
from session import files, run

# The same encoding work on the Dart VM and compiled to JavaScript, run by Node.js.
files("web/ids.dart=ids.dart")
run(
    """
dart run ids.dart
dart compile js -O2 -o build/ids.js ids.dart > /dev/null
node build/ids.js
"""
)

Output

$ dart run ids.dart
id read back: 9007199254740993
id is int: true
re-encoded: {"id":9007199254740993,"paise":49900}
utf8 bytes: 18
base64: 4KSo4KSu4KS44KWN4KSk4KWH
$ dart compile js -O2 -o build/ids.js ids.dart > /dev/null
$ node build/ids.js
id read back: 9007199254740992
id is int: true
re-encoded: {"id":9007199254740992,"paise":49900}
utf8 bytes: 18
base64: 4KSo4KSu4KS44KWN4KSk4KWH

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 web_session.py

On the virtual machine the id reads back as 9007199254740993. In the JavaScript build it reads back as 9007199254740992, and is int is still true, because a web build represents integers as JavaScript numbers, which are doubles with 53 bits of precision. Dart’s own documentation on number representation describes that difference; what the run adds is the cost in one concrete case: an identifier that is one unit wrong, with no exception and no log line. Send identifiers larger than 2^53 as strings, as many payment and ledger systems do, and keep whole numbers that must be exact in int fields that never go near a web build. The UTF-8 and base64 lines of the same run are identical on both targets, which is the part of dart:convert you can rely on everywhere.

Measured on Dart 3.13.5

Both halves of that run were recorded with the SDK this track pins, the JavaScript half with Node.js 24.21.0. The rounding is not a bug awaiting a fix: it follows from how a web build represents integers.

Checkpoint

Check: The type a JSON number decodes to

  1. Question 1 of 2 Which of these bodies decodes to a Dart double?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 1e3

    The text decides: a decimal point or an exponent gives a double, anything else an int. A field that may be whole is therefore read as num.

  2. Question 2 of 2 The recorded run shows the id 9007199254740993 coming back as 9007199254740992 in the JavaScript build. What is the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Send identifiers that large as strings

    A web build represents integers as doubles with 53 bits of precision. Identifiers are not quantities, so sending them as text costs nothing and removes the whole class of problem.

The depth of this lesson goes in two directions from here. One is a model written by hand, where fromJson checks every field with a pattern and refuses a bad body by naming the field that failed, and toJson keeps the round trip honest. The other is the byte side: UTF-8 and base64 measured on real text, and converters bound to a stream so that a character cut in half between two reads still arrives whole.

Practise with a body you do not trust

Build this in a console project: dart create -t console orders, then work in lib/ and bin/.

  1. Write toJson() for an order with a list of items, print it compact and with JsonEncoder.withIndent(' '), and check that the compact form is 82 characters.
  2. Decode that text and print runtimeType for the map, the list and the first item.
  3. Decode '{"amount_paise": 49900, "rate": 1.0}', read both values as num, and throw a FormatException naming the field when a value is not a number.
  4. Encode a map holding 9007199254740993, compile it with dart compile js, run it with node, and compare.

Practice that adapts to you

Practise this lesson

4 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 4 What happens when you pass an object without a toJson method to jsonEncode?

    Choose one answer.

    Show the answer: Question 1 of 4

    Answer: It raises an error saying the object is not encodable

    Give the class a toJson that returns maps, lists and primitives, or pass a toEncodable function to jsonEncode for a type you do not own.

  2. Question 2 of 4 Your test compares your encoded text with a partner's example string and fails, although both decode to the same values. What do you change in the test?

    Choose one answer.

    Show the answer: Question 2 of 4

    Answer: Compare the decoded values instead of the text, unless the format is documented byte for byte

    The encoder writes keys in the order your map holds them. Compare parsed values, and compare text only where a specification says the bytes themselves matter.

  3. Question 3 of 4 A body holds "rate": 1.0 and your code reads it with body['rate'] as int. What does the program do?

    Choose one answer.

    Show the answer: Question 3 of 4

    Answer: It throws a TypeError

    The value decoded to a double, so the cast fails at run time. Reading it as num and calling toDouble, or accepting num in the model, is what survives a sender who writes 1.0 one day and 1 the next.

  4. Question 4 of 4 Your API will be consumed by a Dart web client and by a browser. Which fields do you decide to send as strings, and why?

    Choose one answer.

    Show the answer: Question 4 of 4

    Answer: Identifiers and any whole number that may exceed 53 bits, because that is where a double loses precision

    Identifiers are labels, not quantities, so nothing is lost by sending them as text, and the whole class of rounding problems disappears with them.

A typed model written by hand, with errors that name the field Pro: opens with a Pro, Premium or Ultimate pass

A hand-written fromJson is a few more lines than a cast and a different kind of code: it is the one place where a body a stranger sent becomes a value the rest of the program can trust. This part builds one for a nested order, measures what it does with five bad bodies, and weighs it against generated code.

In this part:

  • Four cases in order, from the most specific to the most general
  • What the run proves about round trips and unknown fields
  • Fields that may be absent, and where the model belongs
  • By hand or generated

1 runnable example (Dart) · about 6 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

From text to bytes: UTF-8, base64 and a character cut in half Pro: opens with a Pro, Premium or Ultimate pass

Below JSON there is a second conversion that most code does without looking: text to bytes. This part measures it on two strings, shows why a URL-safe alphabet exists, and then cuts a character in half between two reads to show what a converter: An object that turns values of one kind into another and can also be bound to a stream, converting each chunk as it arrives and keeping any incomplete state. bound to a stream does that a decode call cannot.

In this part:

  • Code units, bytes and what base64 costs
  • A character split between two reads

1 diagram · 2 runnable examples (Dart) · about 8 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

Three questions from the module’s bank carry this further:

  1. Mid to senior

    A partner's field arrives as 2500 for months and then as "2500" and 2500.0 for some senders. Your handler throws a TypeError in production. How do you make the reader survive all three without accepting nonsense?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    A ledger service sends numeric ids above nine quadrillion. The Dart web client shows an id one unit lower than the server's, with no error. What is happening, and what do you change on each side?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A nightly job reads a 2 GB text export, counts rows per branch and writes a summary, inside a 256 MB memory budget. Design the Dart side, and say how you would prove it stays inside the budget.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does jsonEncode do with an object it does not know?

    It calls the object's toJson() and encodes whatever that returns; it knows maps, lists, strings, numbers, booleans and null itself.

  2. What type does jsonDecode return, and what follows for the code that reads it?

    Object?: the decoded map is a _Map<String, dynamic> holding List<dynamic> values, so every field read is an unchecked claim about the body until your code proves it.

  3. Why compare JSON from another system as parsed values, not as strings?

    JSON objects have no order of their own: the encoded key order is the order your map wrote, and another system may write the same object differently.

  4. When does a decoded JSON number become an int and when a double?

    By its text: no decimal point and no exponent gives an int, anything else a double. A field read as int throws the day the sender writes 1.0, so read a quantity that may be whole as num and call toDouble().

  5. How should a JSON identifier above 2^53 travel when a web build may read it?

    As a string. A web build holds integers as doubles with 53 bits, so 9007199254740993 came back as 9007199254740992, with no exception and is int still true.

  6. How do you print JSON indented for a person to read or diff?

    Pass a string of two spaces to JsonEncoder.withIndent: it is the same encoder with a two-space indent, for a file a person reads or a fixture you diff.

Key takeaways

  • jsonEncode calls toJson() on anything it does not know, so that method defines the shape you send.
  • jsonDecode returns Object?, so every field read afterwards is an unchecked claim until your code proves it.
  • A body without a decimal point decodes to int and one with a decimal point or an exponent to double.
  • A web build holds integers as doubles with 53 bits, so an identifier above 2^53 travels safely only as a string.
  • Code units and bytes are different limits, and a converter bound to a stream keeps an unfinished character or line safe across two reads.

Terms in this lesson

base64
A way of writing arbitrary bytes as 64 printable characters, four characters for every three bytes, defined by RFC 4648; it is an encoding, not a secret.
base64url
The base64 alphabet with - and _ in place of + and /, so a value can travel inside a URL path or query, or in a file name, without being rewritten.
chunked conversion
Converting data piece by piece as it arrives, keeping whatever is incomplete until the next piece, so a stream larger than memory can be processed.
codec
A pair of converters that undo each other, such as utf8 or base64 in dart:convert; each offers a convert call and an encoder and decoder for streams.
converter
An object that turns values of one kind into another and can also be bound to a stream, converting each chunk as it arrives and keeping any incomplete state.
serialisation
Turning a value in memory into a form that can be stored or sent, such as JSON text, and back again; in Dart the outward step is usually a toJson method.
UTF-8
The byte encoding of Unicode text that uses one byte for ASCII characters and two to four for others, so byte size and character count differ.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.