Skip to the code
cf.

Normalize nested data

An API often returns nested data: a post with its author, and its comments with theirs. The same user can appear in many places, so changing them means finding every copy. Normalizing stores each item once, in a table by id, and puts the item's id wherever it was nested. Write normalize(data, schema), which does that.

A schema describes one kind of item: { key, fields }. key names its table, like "users". fields is optional: it maps the name of each field that holds nested items to their schema, either a schema for one item, or an array holding a schema, like [comment], for a list of them. Every item has an id.

schema is the schema of data itself: a schema when data is one item, or an array holding one when data is a list.

normalize returns { result, entities }. result is the id of data, or an array of ids when it's a list. entities is an object with one table for each kind of item found, named by its key. Each table is an object that maps each item's id to a copy of the item in which every field named in fields holds an id, or an array of ids, in place of the nested items.

Each entry is a new object: don't change data. Fields that aren't named in fields are copied as they are, and a field named in fields that's missing, null, or undefined stays that way.

The same item (same key, same id) can appear more than once. Merge its copies into one entry: each later copy adds its fields to the entry, replacing fields with the same name.

const user = { key: "users" };
const comment = { key: "comments", fields: { author: user } };
const post = { key: "posts", fields: { author: user, comments: [comment] } };
 
const posts = [
  {
    id: 1,
    title: "Hello",
    author: { id: 10, name: "Ada" },
    comments: [{ id: 100, text: "Nice", author: { id: 11, name: "Alan" } }],
  },
  { id: 2, title: "Again", author: { id: 10, name: "Ada" }, comments: [] },
];
 
normalize(posts, [post]);
// {
//   result: [1, 2],
//   entities: {
//     users: { 10: { id: 10, name: "Ada" }, 11: { id: 11, name: "Alan" } },
//     comments: { 100: { id: 100, text: "Nice", author: 11 } },
//     posts: {
//       1: { id: 1, title: "Hello", author: 10, comments: [100] },
//       2: { id: 2, title: "Again", author: 10, comments: [] },
//     },
//   },
// }

What the tests check

  1. puts one item in its table and returns its id
    const user = { key: "users" };
    expect(normalize({ id: 1, name: "Ada" }, user)).toEqual({ result: 1, entities: { users: { 1: { id: 1, name: "Ada" } } } });
    
  2. turns a list into an array of ids
    const user = { key: "users" };
    expect(normalize([{ id: 1, name: "Ada" }, { id: 2, name: "Alan" }], [user])).toEqual({
      result: [1, 2],
      entities: { users: { 1: { id: 1, name: "Ada" }, 2: { id: 2, name: "Alan" } } },
    });
    expect(normalize([], [user])).toEqual({ result: [], entities: {} });
    
  3. replaces a nested item with its id
    const user = { key: "users" };
    const post = { key: "posts", fields: { author: user } };
    expect(normalize({ id: 1, title: "Hi", author: { id: 10, name: "Ada" } }, post)).toEqual({
      result: 1,
      entities: {
        posts: { 1: { id: 1, title: "Hi", author: 10 } },
        users: { 10: { id: 10, name: "Ada" } },
      },
    });
    
  4. replaces nested lists with arrays of ids, at any depth
    const user = { key: "users" };
    const comment = { key: "comments", fields: { author: user } };
    const post = { key: "posts", fields: { comments: [comment] } };
    const data = { id: 1, comments: [{ id: 100, text: "a", author: { id: 10 } }, { id: 101, text: "b", author: { id: 11 } }] };
    expect(normalize(data, post)).toEqual({
      result: 1,
      entities: {
        posts: { 1: { id: 1, comments: [100, 101] } },
        comments: { 100: { id: 100, text: "a", author: 10 }, 101: { id: 101, text: "b", author: 11 } },
        users: { 10: { id: 10 }, 11: { id: 11 } },
      },
    });
    
  5. copies fields that aren't in the schema as they are
    const user = { key: "users" };
    const post = { key: "posts", fields: { author: user } };
    const meta = { views: 3 };
    const tags = ["js"];
    const { entities } = normalize({ id: 1, meta, tags, author: { id: 10, prefs: { theme: "dark" } } }, post);
    expect(entities.posts[1].meta).toBe(meta);
    expect(entities.posts[1].tags).toBe(tags);
    expect(entities.users[10]).toEqual({ id: 10, prefs: { theme: "dark" } });
    
  6. leaves missing, null, and undefined nested items as they are
    const user = { key: "users" };
    const comment = { key: "comments" };
    const post = { key: "posts", fields: { author: user, comments: [comment] } };
    const { entities } = normalize([{ id: 1, author: null, comments: null }, { id: 2 }, { id: 3, author: undefined }], [post]);
    expect(entities).toEqual({
      posts: { 1: { id: 1, author: null, comments: null }, 2: { id: 2 }, 3: { id: 3, author: undefined } },
    });
    
  7. merges copies of the same item, later fields winning
    const user = { key: "users" };
    const post = { key: "posts", fields: { author: user } };
    const { entities } = normalize(
      [
        { id: 1, author: { id: 10, name: "Ada", city: "London" } },
        { id: 2, author: { id: 10, name: "Ada L.", avatar: "ada.png" } },
      ],
      [post],
    );
    expect(entities.users).toEqual({ 10: { id: 10, name: "Ada L.", city: "London", avatar: "ada.png" } });
    
  8. follows schemas that refer to themselves
    const user = { key: "users" };
    user.fields = { manager: user, reports: [user] };
    const data = { id: 1, name: "Ada", reports: [{ id: 2, name: "Alan", manager: { id: 1 } }] };
    expect(normalize(data, user)).toEqual({
      result: 1,
      entities: { users: { 1: { id: 1, name: "Ada", reports: [2] }, 2: { id: 2, name: "Alan", manager: 1 } } },
    });
    
  9. leaves the input data alone
    const user = { key: "users" };
    const post = { key: "posts", fields: { author: user } };
    const author = { id: 10, name: "Ada" };
    const data = [{ id: 1, author }];
    const { entities } = normalize(data, [post]);
    expect(data).toEqual([{ id: 1, author: { id: 10, name: "Ada" } }]);
    expect(data[0].author).toBe(author);
    expect(entities.posts[1]).not.toBe(data[0]);
    expect(entities.users[10]).not.toBe(author);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).