Creating Immutable Functional Style Dart Model Objects based on JSON or JSON schemas

Flutter is opinionated about using a functional programming model that includes immutable data objects.  I've found that we tend to ignore that whole immutable thing when dealing with JSON data.  

Projects can be loose when handling incoming JSON data and how it is accessed, sprinkling string-style map keys across the codebase.  We can reduce that tendency by providing models to eliminate the string keys and mutable models to stop people from directly manipulating maps of strings.

Some languages are functional only.  Other languages have no way of generating immutable objects. In those cases, we just end up with objects that can be easily converted into and out of JSON structures and strings.

I'm working in Dart right now so the rest of this will be Dart samples

Modeling Longitude and Latitude Using the JSON Schema

Let's pick something simple as an example.  We're going to use this Latitude and Longitude JSON Schema that follows the schema definition style from http://json-schema.org/draft-06/schema#A coordinate on the earth's surface is represented by a longitude and a latitude.

{
    "id": "http://json-schema.org/geo",
    "$schema": "http://json-schema.org/draft-06/schema#",
    "description": "A geographical coordinate",
    "type": "object",
    "properties": {
      "latitude": {
        "type": "number"
      },
      "longitude": {
        "type": "number"
      }
    }
  }

Watch the video

Incrementally creating the right kind of Dart Model Class

I generated these files using https://app.quicktype.io/ sourced from this GitHub repository  I have no interest in the tool other than it saved me a few days of coding.

Starting with just property definitions


Enabling just Types Only results in
class Coordinate {
    double latitude;
    double longitude;

    Coordinate({
        this.latitude,
        this.longitude,
    });
}

Making the objects immutable, unchangeable after creation.

We want to operate in a Functional Programming model with immutable objects. Enabling Make all properties required and Make all properties final results in. Note the added final and required.

class Coordinate {
    final double latitude;
    final double longitude;

    Coordinate({
        required this.latitude,
        required this.longitude,
    });

}

Simplify creating mutated copies of the data.

Information changes.  This means we need a way of creating new model objects that have one or more property differences from the object they were created from.

We want to make it easy on the programmer so we enable Generate CopyWith that creates a new instance by making a copy with the requested changes applied. Note the added CopyWith() method.

class Coordinate {
    final double latitude;
    final double longitude;

    Coordinate({
        required this.latitude,
        required this.longitude,
    });

    Coordinate copyWith({
        double? latitude,
        double? longitude,
    }) =>
        Coordinate(
            latitude: latitude ?? this.latitude,
            longitude: longitude ?? this.longitude,
        );
}

We're all about serializing and deserializing our model with JSON

The next step is to add JSON serialization and deserialization support.  quicktype can generate global functions or static methods on the class. I'm a classy sort of person so I'll do the latter by turning off the Types only selector.  We get a class that can 

  1. Create a model instance from a JSON string.
  2. Create a model instance from a Map<String, dynamic>
  3. Create a JSON data structure from a model object instance.
  4. Create a JSON string from a model object instance.

import 'dart:convert';

class Coordinate {
    final double latitude;
    final double longitude;

    Coordinate({
        required this.latitude,
        required this.longitude,
    });

    Coordinate copyWith({
        double? latitude,
        double? longitude,
    }) =>
        Coordinate(
            latitude: latitude ?? this.latitude,
            longitude: longitude ?? this.longitude,
        );

    factory Coordinate.fromRawJson(String str) => Coordinate.fromJson(json.decode(str));

    String toRawJson() => json.encode(toJson());

    factory Coordinate.fromJson(Map<String, dynamic> json) => Coordinate(
        latitude: json["latitude"]?.toDouble(),
        longitude: json["longitude"]?.toDouble(),
    );

    Map<String, dynamic> toJson() => {
        "latitude": latitude,
        "longitude": longitude,
    };
}

Using Annotations to Generate Serialization Code

As an alternative... We can reduce the boiler-plate code by using annotations to help us generate the serialization code as part of the build process. 

import 'package:meta/meta.dart'; import 'package:json_annotation/json_annotation.dart'; import 'dart:convert'; part 'coordinate.g.dart'; @JsonSerializable() class Coordinate { @JsonKey(name: "latitude") final double latitude; @JsonKey(name: "longitude") final double longitude; Coordinate({ required this.latitude, required this.longitude, }); Coordinate copyWith({ double? latitude, double? longitude, }) => Coordinate( latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, ); factory Coordinate.fromJson(Map<String, dynamic> json) => _$CoordinateFromJson(json); Map<String, dynamic> toJson() => _$CoordinateToJson(this); }

Endgame

Real-world JSON schemas and modeling are a lot more complicated than this simple example.  There are a variety of tools out there.  At the time of this article, I'd recommend exploring JSON modeling including functional programming if your language supports it.  I used https://app.quicktype.io but there are other tools out there.

Revision History

Created 2023/09

Comments

Popular posts from this blog

Understanding your WSL2 RAM and swap - Changing the default 50%-25%

Installing the RNDIS driver on Windows 11 to use USB Raspberry Pi as network attached

DNS for Azure Point to Site (P2S) VPN - getting the internal IPs