(De-)Serializing JavaScript Models with Metaprogramming

When working with JSON data or similar serialization formats, I’ve come to appreciate the value of model classes instead of just using plain objects. So when I find myself deserializing records, processing them and serializing them again, e.g. for transmission via HTTP or local storage, I might create a simplistic implementation at first:

class Book {
    constructor(title, author) {
        this.title = title;
        this.author = author;
    }

    toJSON() {
        let { title, author } = this;
        return { title, author };
    }

    static fromJSON({ title, author }) {
        return new this(title, author);
    }
}

let book = Book.fromJSON(payload);
// …
payload = JSON.stringify(book);

Already dangerously repetitive, this gets unwieldy quickly as we add more properties and create additional models. It also seems kind of silly to de- and recompose the underlying data structure when (de-)serializing. Thus we might cut out the middleman by storing the original data object and selectively delegating property access from our model instance to that object - essentially keeping data separate (hardly a new idea, of course) and layering a facade on top:

class Book {
    constructor(data) {
        this._data = data;
    }

    toJSON() {
        return this._data;
    }

    get title() {
        return this._data.title;
    }

    get author() {
        return this._data.author;
    }
}

let book = new Book(payload);
// …
payload = JSON.stringify(book);

This is the read-only version; filling in setters should be fairly straightforward if you need them. You might also want to validate the data object (e.g. using joi, though I’m partial to declepticon) or avoid mutability issues by cloning that object within both the constructor and #toJSON.

Next we can extract common boilerplate into a base class:

class Record {
    constructor(data) {
        this._data = data;
    }

    toJSON() {
        return this._data;
    }
}

class Book extends Record {
    get title() {
        return this._data.title;
    }

    get author() {
        return this._data.author;
    }
}

This is starting to look more declarative, without obscuring what’s actually going on. Depending on the number and complexity of your models, it might still be too repetitive though, making it arduous for readers to grok the code; consider a simple example with multiple models. In that case, you might want something that looks more like this:

export default class Book extends Record {
    static get slots() {
        return ["title", "author"];
    }
}

Except now we have to make our Record base class generate those property accessors, meaning we have to engage in some light metaprogramming – often an indicator that you might wanna reconsider, but let’s see what that might look like. Delegating property access essentially means our model class acts as a proxy – except I couldn’t use Proxy for compatibility reasons, ergo DIY:

export default class Record {
    constructor(data) {
        this._data = data;

        // generate accessors on first invocation
        let ctor = this.constructor;
        if(!ctor._initialized) {
            ctor.slots.forEach(slot => {
                makeGetter(ctor.prototype, slot);
            });
            ctor._initialized = true;
        }
    }

    toJSON() {
        return this._data;
    }
}

function makeGetter(proto, slot) {
    Object.defineProperty(proto, slot, {
        get() {
            return this._data[slot];
        }
    });
}

While that works nicely for primitive data structures, if we want models referencing other models we’ll need to support nested data transformations:

export default class Book extends Record {
    static get slots() {
        return {
            title: null, // no transformation
            author: name => new Author(name),
            isPublished: value => !!value // normalize, because we can
        }
    }
}

That requires slightly adjusting our base class:

// generate accessors
Object.entries(ctor.slots).forEach(([slot, handler]) => {
    makeGetter(ctor.prototype, slot, handler);
});
function makeGetter(proto, slot, handler) {
    Object.defineProperty(proto, slot, {
        get() {
            let value = this._data[slot];
            return handler ? handler(value) : value;
        }
    });
}

I’m pretty sure this already exists as a library or within some framework (pointers are welcome in the comments), but I just needed a no-frills approach and writing that myself proved easier than trawling the web; YMMV.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie Vereinbarung zustimmen. Mehr lesen