blitz-js / superjson

Safely serialize JavaScript expressions to a superset of JSON, which includes Dates, BigInts, and more.
MIT License
3.88k stars 83 forks source link

Nested custom recipe (registerCustom feedback) #280

Closed Lilja closed 7 months ago

Lilja commented 7 months ago

I have a Result utility, that helps me write result-based interfaces, like how rust does it. I'm using tRPC between the client and server, and I would like to re-use my utility over the wire.

It looks sort of like this:

const Ok = { tag: "Ok", value: "..." };
const Err = { tag: "Error", error: "..." };
const Result = {
  isOk: () => {},
  isErr: () => {},
  isResult: () => {},
}

// I merge Result with either an `Ok` or `Err`, making `Result`:
const result: Result<string, Error> = Result.Ok("Hi"); // { tag: "Ok", value: "Hi", isOk: () => {}, isErr: () => {}, isResult: () => {} }

Running SuperJSON as is works great, except for that the functions are not persisted over wire. Which is totally expected. You would need some sort of custom recipe.

The problem which I don't fully understand is how I would use registerCustom if my data is say Result<number, Error>.

type SuperJsonOutput = {tag: "Ok" | "Err", payload: string};

SuperJSON.registerCustom<Result<unknown, unknown>, SuperJsonOutput>({
 isApplicable: (value): value is Result<unknown, unknown> =>Result.isResult(value),
 serialize: (value: Result<unknown, unknown>) => {
      if (value.isOk()) {
        return {tag: "Ok", payload: JSON.stringify(value.value)};
      }
      return {tag: "Err", payload: JSON.stringify(value.error)};
    },
 deserialize: (value: SuperJsonOutput) => {
      if (value.tag === "Ok") {
        return Ok(value.payload);
      } else {
        return Err(value.payload);
      }
    },
  },
  'Result'
);

But since I JSON.stringify, it messes up the number. In this case I would expect another parameter that I could "continue" the serialization:

const whatevs = {
 serialize: (value: Result<unknown, unknown>, _serializer: SuperJSON) => {
      if (value.isOk()) {
        return {tag: "Ok", payload: _serializer(value.value)};
      }
      return {tag: "Err", payload: _serializer(value.error)};
    },
}

My test, where I use Result<number, Error>.

import { expect, test } from "vitest";
import superjson from "superjson";
import {Result, superJsonRegister} from "../src/result";

test("registerCustom test", () => {
  superjson.registerCustom(superJsonRegister(superjson), "Result");
  const result = Result.Ok<number, Error>(1);
  const serialized = superjson.serialize(result);
  const deserialized = superjson.deserialize<Result<number, Error>>(serialized);
  if (deserialized.isOk()) {
    expect(deserialized.value).toEqual(result.value);
  }
  else {
    throw new Error("deserialized is not ok");
  }
});
 FAIL  tests/superjson.test.ts > registerCustom test
AssertionError: expected '1' to deeply equal 1

- Expected:
1

+ Received:
"1"

Any thoughts on this?

Lilja commented 7 months ago

I think I solved it:

type SuperJsonOutput = {tag: "Ok" | "Err", payload: string};

export const superJsonRegister = (
  _superjson: typeof superjson,
): Parameters<
  typeof superjson.registerCustom<Result<unknown, unknown>, SuperJsonOutput>
>[0] => {
  return {
    isApplicable: (value): value is Result<unknown, unknown> =>
      Result.isResult(value),
    serialize: (value: Result<unknown, unknown>) => {
      if (value.isOk()) {
        return {tag: "Ok", payload: _superjson.stringify(value.value)};
      }
      return {tag: "Err", payload: _superjson.stringify(value.error)};
    },
    deserialize: (value: SuperJsonOutput) => {
      if (value.tag === "Ok") {
        return Ok(_superjson.parse(value.payload));
      } else {
        return Err(_superjson.parse(value.payload));
      }
    },
  };

Which works with


test("number", () => {
    superjson.registerCustom(superJsonRegister(superjson), "Result");
    const result = Result.Ok<number, Error>(1);
    const serialized = superjson.serialize(result);
    const deserialized = superjson.deserialize<Result<number, Error>>(serialized);
    if (deserialized.isOk()) {
      expect(deserialized.value).toEqual(result.value);
    }
    else {
      throw new Error("deserialized is not ok");
    }
  });