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.
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.
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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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.
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.
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
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/.
- Write
toJson()for an order with a list of items, print it compact and withJsonEncoder.withIndent(' '), and check that the compact form is 82 characters. - Decode that text and print
runtimeTypefor the map, the list and the first item. - Decode
'{"amount_paise": 49900, "rate": 1.0}', read both values asnum, and throw aFormatExceptionnaming the field when a value is not a number. - Encode a map holding 9007199254740993, compile it with
dart compile js, run it withnode, 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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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:
-
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?
-
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?
-
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.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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().
-
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.
-
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
jsonEncodecallstoJson()on anything it does not know, so that method defines the shape you send.jsonDecodereturnsObject?, so every field read afterwards is an unchecked claim until your code proves it.- A body without a decimal point decodes to
intand one with a decimal point or an exponent todouble. - 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
- dart:convert (The Dart project (Google))
- JSON and serialization (The Dart project (Google))
- JsonEncoder class (The Dart project (Google))
- Utf8Codec class (The Dart project (Google))
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format (IETF)
- RFC 4648: The Base16, Base32, and Base64 Data Encodings (IETF)
- Numbers in Dart (The Dart project (Google))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress