tjjfvi/subshape

$assert on value that is null

davit-b opened this issue · 3 comments

Code

const breaking_object = {
  author: "acqq",
  num_comments: null, // this value === number | null
  objectID: "12343",
  points: 34,
  title: null,  // this value === string | null
  url: null, // this value === string | null
}

const $shape = $.object(
  $.field("author", $.str),
  
  $.optionalField("num_comments", $.u32), 
  // optionalField doesn't work for the null input. I tried a literal union here 
  // but it gave me an error `typeof value.num_comments !== "string"` 
  // maybe the literal union only allows string literals? 
  
  $.field("objectID", $.str),
  $.field("points", $.u32),
  
  // literalUnions with null constant does not work for the null inputs above. 
  $.optionalField("title", $.literalUnion([$.str, $.constant(null)])), 
  $.optionalField("url", $.literalUnion([$.str, $.constant(null)])), 
)

$.assert($shape, breaking_object)

Error

typeof value.num_comments !== "number"
  ​​​​​at ​​​​​​​​AssertState.typeof​​​ ​./node_modules/scale-codec/src/common/assert.ts:25​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/int.ts:37​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/option.ts:34​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/object.ts:40​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/object.ts:79​
  ​​​​​at ​​​​​​​​Object.assert​​​ ​./node_modules/scale-codec/src/common/codec.ts:140​
  ​​​​​at ​​​​​​​​test​​​ ​src/utility/play.ts:33:3​
  ​​​​​at ​​​​​​​​Object.<anonymous>​​​ ​src/utility/play.ts:36:1

typeof value.title !== "string"
  ​​​​​at ​​​​​​​​AssertState.typeof​​​ ​./node_modules/scale-codec/src/common/assert.ts:25​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/union.ts:87​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/option.ts:34​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/object.ts:40​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/object.ts:79​
  ​​​​​at ​​​​​​​​Object.assert​​​ ​./node_modules/scale-codec/src/common/codec.ts:140​
  ​​​​​at ​​​​​​​​test​​​ ​src/utility/play.ts:33:3​
  ​​​​​at ​​​​​​​​Object.<anonymous>​​​ ​src/utility/play.ts:36:1
  
typeof value.url !== "string"
  ​​​​​at ​​​​​​​​AssertState.typeof​​​ ​./node_modules/scale-codec/src/common/assert.ts:25​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/union.ts:87​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/option.ts:34​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/object.ts:40​
  ​​​​​at ​​​​​​​​Codec._assert​​​ ​./node_modules/scale-codec/src/codecs/object.ts:79​
  ​​​​​at ​​​​​​​​Object.assert​​​ ​./node_modules/scale-codec/src/common/codec.ts:140​
  ​​​​​at ​​​​​​​​test​​​ ​src/utility/play.ts:33:3​
  ​​​​​at ​​​​​​​​Object.<anonymous>​​​ ​src/utility/play.ts:36:1

I'm not sure what I'm doing wrong here.
The literalUnion does not work for null fields, and the optionalField doesn't work for the null input.

Do I need some new function like zod's Nullable?

Would appreciate any guidance :)

Well, there are two things going on here.

$.literalUnion is supposed to work for non-string values (as of #137), but it seems I failed to remove the typeof === "string" assertion.

However, even if it was fixed, it wouldn't exactly work for your usecase, as $.literalUnion([$.str, $.constant(null)]) isn't a $.Codec<string | null>, but a $.Codec<$.Codec<string> | $.Codec<null>>.

We don't have a built-in codec for T | null – we prefer to instead use T | undefined with $.option (as you're doing with $.optionalField), but you can write a custom one:

export function $nullable<T>($inner: Codec<T>): Codec<T | null> {
  return createCodec({
    _metadata: metadata("$nullable", $nullable, $inner),
    _staticSize: 1 + $inner._staticSize,
    _encode(buffer, value) {
      if (value === null) {
        buffer.array[buffer.index++] = 0
      } else {
        buffer.array[buffer.index++] = 1
        $inner._encode(buffer, value)
      }
    },
    _decode(buffer) {
      return buffer.array[buffer.index++] ? $inner._decode(buffer, value) : null
    }
    _assert(assert) {
      if (assert.value !== null) {
        $inner._assert(assert)
      }
    },
  })
}

It's worth noting, however, that if you write $.optionalField("foo", $nullable($bar)) it will encode two discriminant bytes (the first for if foo is defined, and the second for if it's non-null). If you're using this codec for your own purposes (i.e., you don't care about the exact format), this is fine, but if you're trying to encode/decode data to another implementation of scale, this kind of codec probably isn't what you want. You'll either want $.optionalField("foo", $bar) or $.field("foo", $nullable($bar)), which will both only encode one discriminant byte (and also only support one way to represent None).

encode two discriminant bytes (the first for if foo is defined, and the second for if it's non-null).

So I should determined how the breaking object represents None, and decide from there.

  • If None is represented as defined | undefined --> use $.optionalField("foo", $bar) [uses 1 byte discriminant]
  • If None is represented as non-null | null --> use $.field("foo", $nullable($bar)) [uses 1 byte discriminant]
  • If it's represented both ways, then I'm forced to use $.optionalField("foo", $nullable($bar)) [uses 2 bytes for 2x discriminants]
    It's just a waste because it will never be undefined && null.

I just tested all 3 cases and the above $nullable works. :) :) Love this lib.

Btw, I had to remove the value field from the following to get it working.

_decode(buffer) {
  return buffer.array[buffer.index++] ? $inner._decode(buffer) : null
},

I looked through https://github.com/paritytech/scale-ts/tree/main/codecs in case the other Codec's took a value argument on the _decode function and couldn't find any, so I assume it shouldn't be there.

So I should determined how the breaking object represents None, and decide from there.

Exactly!

I just tested all 3 cases and the above $nullable works. :) :) Love this lib.

Glad you're enjoying it :)

Btw, I had to remove the value field from the following to get it working.

Ah, yes, you're right. I suppose I should've tested (or at least type-checked) the code before I posted it ;)