Thank you, though all the credit belongs to @michalmuskala really.
Nope, just basic encoding and decoding currently, you’ll need to use another package for other features. It could be something to add in future, though I’m unlikely to get to it for some time as I’ve a long list of things I want for Gleam first.
We have now Thoas as standard json library in Nova. You can choose what library you want to use but this was a really good one so we changed the library we used.
Before it is too late, would you consider changing the decode/1 function’s return format to return just the result instead of {ok, ...} | {error, ...}? This way it can be used as a drop-in replacement for any other JSON erlang library, since all the major ones use decode(binary()) -> json() API spec. See jiffy / jsone / jsx
The API is designed to never throw, so instead it returns a result tuple.
It wouldn’t be hard to wrap it in a function which throws if an error is returned. Would adding that function to the user’s codebase be sufficient, or would it be a notable UX improvement to have a thoas_throwing module which exposes that API?
I am in favour of suggestion @seriyps made.
Using tuples makes code less readable, and pushes developers towards bubble-wrapping. Example:
do_steps(Arg) ->
case step1(Arg) of
{ok, Success} ->
case step2(Success) of
{ok, Success2} ->
case step3(Success2) of
{ok, Success3} ->
{ok, Success3};
{error, Failure3}
{error, {step3_failure, Failure3}}
end;
{error, Failure2} ->
{error, {step2_failure, Failure2}}
end;
{error, Failure} ->
{error, {step1_failure, Failure}}
end.
What the developer actually means is:
do_steps(Arg) ->
step3(step2(step1(Arg))).
In most cases “no throw return code” still has to be somehow checked for an error. Often this way:
{ok, Res} = do_steps(MyArg)
Bubble-wrapping has whole lot of associated problems. First, it usually tries to re-create call stack using the Reason in {error, Reason} tuple. Second, it’s cluttering the logic with error wrapping clauses. Third, the actual underlying error tends to be lost in translation (original stack trace is also lost).
Having said that, I would rather have thoas_nothrow returning tuples than thoas_throwing.
Another idea is, if this implementation is faster and 100% compatible with jsone, it makes sense to have a property-based test (jsone vs thoas) proving compatibility. This would let many developers to switch transparently. I would be interested in that.
I have nothing against functions returning {ok, Result} | {error, ..} in general. I’m just trying to highlight the fact that all the other JSON libraries (as well as stdlib’s binary:decode_hex/1 and base64:decode/1) return just the Result from decode/1,2 and throw an exceptions on error.
Developers are used to this API and might be confused that thoas breaks this “unwritten rule”. And also it would be more difficult to swap JSON library in a drop-in like sed -i s/jiffy:/thoas:/-style.
Using tuples makes code less readable, and pushes developers towards bubble-wrapping.
Thoas will not default to throwing I’m afraid. I feel strongly that APIs that throw are surprising and error prone, and there’s currently no Erlang static analysis tooling that can assist you in using or refactoring code that throws. Returning values on the other hand is explicit and immediately clear to the user.
Cloning the API of another existing library isn’t a goal so I’m not willing to make what I see as a detrimental change to it in order to meet this non-goal.
Easily solved with sed -i s/jiffy:/thoas_throwing:/, though given all the libraries above accept different options and have slightly different formats for decoded JSON I don’t think the idea of a standardised API is as true as we’re making it out to be here.
2> thoas:encode(#{"hello" => <<"world">>}).
<<"{\"hello\":\"world\"}">>
3> thoas:encode(#{"привет" => <<"world">>}).
** exception error: bad argument
in function list_to_binary/1
called as list_to_binary([1087,1088,1080,1074,1077,1090])
4> thoas:encode(#{"hello" => make_ref()}). % yes, we are not obeying type specification here
** exception error: no function clause matching thoas_encode:value(#Ref<0.1085671603.2705588226.206231>,
#Fun<thoas_encode.0.100822224>) (/home/seriy/workspace/thoas/src/thoas_encode.erl, line 1717)
It does not feel idiomatic to Erlang. It is harder to read compared to just step1(Arg). It may be slower and create more garbage due to bubble-wrapping. It loses original stack trace. I would even dare to say it is not beautiful, because code contains useless repetitive characters {ok, Success}.
I am not a supporter of EEP-49 for these specific reasons. It turns Erlang into C by requiring to test every return code, obscuring “happy path”.
Using exceptions allows to handle the error exactly at the level where there is enough context for handling it. In my experience, bubble-wrapped error handling usually looks this way:
handle_call(do_something, _From, State) ->
case do_something() of
{ok, Ret} ->
{reply, Ret, State};
{error, Reason} ->
% swallow all errors and be done with it
?LOG_ERROR("Error, reason ~p", [Reason]),
{noreply, State}
end.
In some cases “error handling” is slightly more elaborate, silently swallowing only selected errors (e.g. malformed JSON) and logging all others.
I find it more powerful to handle errors this way:
This catch does not need to handle all possible exceptions locally. There can be a higher-level try that handles, for example, error:{can_handle_on_higher_level, Info} that had more surrounding context.
Given that pretty much any Erlang code may throw, I prefer to stick to a single idiom and use exceptions for error handling.
I agree with the “static analysis tools” part, tools are not there yet (would be great to fix that).
I disagree with “surprising and error prone”. But I must admit I felt that way before. Could be my personal experience, but having rewritten quite an amount of Erlang code I think I understood exceptions better and fell in love with this concept. It definitely improved conciseness of my code. Made it beautiful.
Use decode/encode to raise and introduce maybe_encode/maybe_decode for ok/error
Keep decode/encode as is and introduce encode_valid/decode_valid (the idea here is that you expect it to be valid and therefore it should raise if not)
Use a separate module but it slightly feels like overkill
I see jsone library exports try_encode / try_decode functions for {ok, ..} | {error, ..} and encode / decode which return plain results and throw on error.