Oskar Dudycz

Pragmatic about programming

Fun with serial JSON

2023-03-05 oskar dudyczJavaScript

cover

JSON serialisation is so much fun. We can make jokes and curse, but we must live with it. Surprisingly, that’s not getting simpler if we use JavaScript or TypeScript. It may look simple as we have JSON.parse and JSON.stringify to make the mapping to text representation back and forth. That’s correct until we use more advanced types.

For JSON Date and BigInt are already too advanced. Both are not defined in the JSON standard. Date will be serialised to string and BigInt? Will fail with error… What to do if we have such fields?

Let’s say that we have the following type of definition representing the Shopping Cart events:

type ShoppingCartEvent =
  | {
      type: 'ShoppingCartOpened';
      data: {
        shoppingCartId: string;
        clientId: string;
        openedAt: Date;
      };
    }
  | {
      type: 'ProductItemAddedToShoppingCart';
      data: {
        shoppingCartId: string;
        productItem: PricedProductItem;
      };
    }
  | {
      type: 'ProductItemRemovedFromShoppingCart';
      data: {
        shoppingCartId: string;
        productItem: PricedProductItem;
      };
    }
  | {
      type: 'ShoppingCartConfirmed';
      data: {
        shoppingCartId: string;
        confirmedAt: Date;
      };
    }
  | {
      type: 'ShoppingCartCanceled';
      data: {
        shoppingCartId: string;
        canceledAt: Date;
      };
    };

We’d like to have it typed and not need additional mapping in our business code. Still, based on what I wrote above, this will not (de)serialise correctly. What to do?

The first option is to define explicit type representing serialised payload.

export type ShoppingCartEventPayload =
  | {
      type: 'ShoppingCartOpened';
      data: {
        shoppingCartId: string;
        clientId: string;
        openedAt: string;
      };
    }
  | {
      type: 'ProductItemAddedToShoppingCart';
      data: {
        shoppingCartId: string;
        productItem: PricedProductItem;
      };
    }
  | {
      type: 'ProductItemRemovedFromShoppingCart';
      data: {
        shoppingCartId: string;
        productItem: PricedProductItem;
      };
    }
  | {
      type: 'ShoppingCartConfirmed';
      data: {
        shoppingCartId: string;
        confirmedAt: string;
      };
    }
  | {
      type: 'ShoppingCartCanceled';
      data: {
        shoppingCartId: string;
        canceledAt: string;
      };
    };

Having it, we can define explicit mapping like that:

const ShoppingCartEventSerde = {
  serialize: ({ type, data }: ShoppingCartEvent): ShoppingCartEventPayload => {
    switch (type) {
      case 'ShoppingCartOpened': {
        return {
          type,
          data: { ...data, openedAt: data.openedAt.toISOString() },
        };
      }
      case 'ProductItemAddedToShoppingCart': {
        return { type, data };
      }
      case 'ProductItemRemovedFromShoppingCart': {
        return { type, data };
      }
      case 'ShoppingCartConfirmed': {
        return {
          type,
          data: { ...data, confirmedAt: data.confirmedAt.toISOString() },
        };
      }
      case 'ShoppingCartCanceled': {
        return {
          type,
          data: { ...data, canceledAt: data.canceledAt.toISOString() },
        };
      }
    }
  },
  deserialize: ({
    type,
    data,
  }: ShoppingCartEventPayload): ShoppingCartEvent => {
    switch (type) {
      case 'ShoppingCartOpened': {
        return {
          type,
          data: { ...data, openedAt: new Date(data.openedAt) },
        };
      }
      case 'ProductItemAddedToShoppingCart': {
        return { type, data };
      }
      case 'ProductItemRemovedFromShoppingCart': {
        return { type, data };
      }
      case 'ShoppingCartConfirmed': {
        return {
          type,
          data: { ...data, confirmedAt: new Date(data.confirmedAt) },
        };
      }
      case 'ShoppingCartCanceled': {
        return {
          type,
          data: { ...data, canceledAt: new Date(data.canceledAt) },
        };
      }
    }
  },
};

And use it as:

const serialisedJSON = JSON.stringify(ShoppingCartEventSerde.serialize(event));

const deserialised = ShoppingCartEventSerde.deserialize(JSON.parse(serialisedJSON));

It may sound redundant, but we could also use it for more advanced mapping, e.g. to version our events to keep backward compatibility.

This pattern is also helpful in more generic scenarios, like handling Web API payload parsing.

What if you don’t want to define a specific class but have it more generic? Not an issue. You can use the following parser:

export const JSONParser = {
  stringify: <From, To = From>(
    value: From,
    options?: StringifyOptions<From, To>
  ) => {
    return JSON.stringify(
      options?.map ? options.map(value as MapperArgs<From, To>) : value,
      options?.replacer
    );
  },
  parse: <From, To = From>(
    text: string,
    options?: ParseOptions<From, To>
  ): To | undefined => {
    const parsed: unknown = JSON.parse(text, options?.reviver);

    if (options?.typeCheck && !options?.typeCheck<To>(parsed))
      throw new ParseError(text);

    return options?.map
      ? options.map(parsed as MapperArgs<From, To>)
      : (parsed as To | undefined);
  },
};

Plus some typing to make TypeScript happy:

export type ParseOptions<From, To = From> = {
  reviver?: (key: string, value: unknown) => unknown;
  map?: Mapper<From, To>;
  typeCheck?: <To>(value: unknown) => value is To;
};

export type StringifyOptions<From, To = From> = {
  map?: Mapper<From, To>;
  replacer?: (key: string, value: unknown) => unknown
};
export class ParseError extends Error {
  constructor(text: string) {
    super(`Cannot parse! ${text}`);
  }
}

export type Mapper<From, To = From> =
  | ((value: unknown) => To)
  | ((value: Partial<From>) => To)
  | ((value: From) => To)
  | ((value: Partial<To>) => To)
  | ((value: To) => To)
  | ((value: Partial<To | From>) => To)
  | ((value: To | From) => To);

export type MapperArgs<From, To = From> = Partial<From> &
  From &
  Partial<To> &
  To;

It allows specifying additional mappers to map JSON back and forth, plus also reviver and replacer methods to convert types automatically to Date and to BigInt. In our case, that’d not need an addtional serde type. We could also inline mappings to support payload versioning strategies.

Cheers!

Oskar

p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help. You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to Red Cross, Ukraine humanitarian organisation or donate Ambulances for Ukraine.

👋 If you found this article helpful and want to get notification about the next one, subscribe to Architecture Weekly.

✉️ Join over 3600 subscribers, get the best resources to boost your skills, and stay updated with Software Architecture trends!

Loading...
Event-Driven by Oskar Dudycz
Oskar Dudycz For over 15 years, I have been creating IT systems close to the business. I started my career when StackOverflow didn't exist yet. I am a programmer, technical leader, architect. I like to create well-thought-out systems, tools and frameworks that are used in production and make people's lives easier. I believe Event Sourcing, CQRS, and in general, Event-Driven Architectures are a good foundation by which this can be achieved.