Stream: API design

Topic: (Not) implementing `encode_null` for an encoder


view this post on Zulip Jasper Woudenberg (Jul 16 2026 at 18:13):

I noticed the encoder for Json in the standard library contains a method encode_null. I'm working on a library for a data format supporting Roc's data structures (Rvn), and I don't think I can implement it for Rvn. I wonder what the implications are for users of Rvn.

The reason that I don't think I can implement encode_null for Rvn is that I don't think the types work out. The central idea for Rvn is that any encoded value is a valid Roc expression. Roc however has no value/type that unifies with any other type, like null. That means that if some value might sometimes be encoded as null, my encoder would need to wrap that value in a Try(..), or similar type. But I don't think there's a way for my encoder to know that a value's encoder might call encode_null. I imagine other typed encodings, protocol buffers for instance, will have similar trouble.

Is the implication here that by not implementing encode_null, any type that calls encode_null will not be encode-able with Rvn? I can see some upsides to this (no need to limit encoders to the lowest common denominator of all encoding formats), but also downsides (a complicated table of which types and encoders are compatible).

view this post on Zulip Jasper Woudenberg (Jul 16 2026 at 18:26):

Possibly I'm making this bigger than it needs to be and I'm missing something obvious, but in case I'm not: one way to still offer null-encoding option compatible with typed encoding formats is to replace encode_null with encode_nullable, i.e. force every value that might be encoded as null through the same function, so encoders know a value passed its way is nullable.

view this post on Zulip Anton (Jul 17 2026 at 18:01):

Some useful info: the compiler only requires encode_null from a format when the value being encoded contains the marker type Try(a, [Null]).

You can use the tag Null in Roc:

diff --git a/package/Encoder.roc b/package/Encoder.roc
index d1b9d7a..09eeab2 100644
--- a/package/Encoder.roc
+++ b/package/Encoder.roc
@@ -65,6 +65,11 @@ Encoder := {}.{
    encode_f64 : F64, State -> Try(State, Error)
    encode_f64 = |value, state| Ok(state.concat(value.to_str()))

+   # Called for values of type `Try(a, [Null])`, the only types the compiler
+   # considers nullable. `Null` is a valid Roc expression, unlike JSON's `null`.
+   encode_null : State -> Try(State, Error)
+   encode_null = |state| Ok(state.concat("Null"))
+
    rename_field : Encoder, Str -> Str
    rename_field = |_, name| name

diff --git a/package/Parser.roc b/package/Parser.roc
index d095284..218155b 100644
--- a/package/Parser.roc
+++ b/package/Parser.roc
@@ -1,7 +1,7 @@
 Parser := {}.{
    State := { bytes : List(U8) }

-   Error : [BytesRemain]
+   Error : [BytesRemain, ExpectedNull]

    parse_str : Parser, State -> Try({ value : Str, rest : State }, Error)
    parse_str = |_parser, _state| {
@@ -78,6 +78,20 @@ Parser := {}.{
        crash "unimplemented `parse_f64`"
    }

+   # Called for values of type `Try(a, [Null])`. Returning `Err` here is not
+   # fatal: it tells the derived parser to parse a payload value instead.
+   parse_null : Parser, State -> Try(State, Error)
+   parse_null = |_parser, { bytes }| {
+       is_identifier_byte = |byte|
+           (byte >= 'a' and byte <= 'z') or (byte >= 'A' and byte <= 'Z') or (byte >= '0' and byte <= '9') or byte == '_'
+       match bytes {
+           # `Null` must not be the start of a longer identifier such as `Nullable`.
+           ['N', 'u', 'l', 'l', next, ..] if is_identifier_byte(next) => Err(ExpectedNull)
+           ['N', 'u', 'l', 'l', .. as rest] => Ok(State.{ bytes: rest })
+           _ => Err(ExpectedNull)
+       }
+   }
+
    parse_array_start : Parser, State -> Try(State, Error)
    parse_array_start = |_parser, _state| {
        crash "unimplemented `parse_array_start`"
diff --git a/package/Rvn.roc b/package/Rvn.roc
index f37e58a..5140f83 100644
--- a/package/Rvn.roc
+++ b/package/Rvn.roc
@@ -34,6 +34,23 @@ expect Str.from_utf8(Rvn.encode([1.U8, 2.U8, 3.U8]))? ==
    \\  3,
    \\]

+nullable_absent : Try(Str, [Null])
+nullable_absent = Err(Null)
+
+nullable_present : Try(Str, [Null])
+nullable_present = Ok("Hello!")
+
+expect Str.from_utf8(Rvn.encode(nullable_absent))? == \\Null
+expect Str.from_utf8(Rvn.encode(nullable_present))? == \\"Hello!"
+expect Str.from_utf8(Rvn.encode({ name: "Fred", pet: nullable_absent }))? ==
+   \\{
+   \\  name: "Fred",
+   \\  pet: Null,
+   \\}
+expect Rvn.parse("Null".to_utf8()) == Ok(nullable_absent)
+expect Parser.parse_null(Parser.{}, Parser.State.{ bytes: "Null]".to_utf8() }) == Ok(Parser.State.{ bytes: "]".to_utf8() })
+expect Parser.parse_null(Parser.{}, Parser.State.{ bytes: "Nullable".to_utf8() }) == Err(ExpectedNull)
+
 Rvn :: {}.{
    parse : List(U8) -> Try(a, Parser.Error)
        where [

Would this work for Rvn?

view this post on Zulip Jasper Woudenberg (Jul 17 2026 at 18:49):

I think your solution doesn't quite meet my design goal of Rvn output always being valid Roc expressions. For instance, encoding [Ok("Hello!"), Err(Null)] would result in ["Hello", Null], which is not legal in Roc.

Even if it's possible to anticipate the standard library's use of encode_null though, I'm concerned people might make use of encode_null in their own types, and by doing so unintentionally lock their types out of being compatible with some encoder formats.

view this post on Zulip Richard Feldman (Jul 18 2026 at 22:47):

I'm looking into this btw - I basically want to change it so that only Json has any concept of Null

view this post on Zulip Richard Feldman (Jul 18 2026 at 22:47):

encode_null was kind of a quick-and-dirty "get JSON unblocked" and I forgot to circle back to it :sweat_smile:


Last updated: Jul 23 2026 at 13:15 UTC