Skip to content
How Answers Are Decoded

How Answers Are Decoded

A SystemOneResponse carries a map of answers, and each answer’s shape depends on the kind of question it answers. A single json.Unmarshal cannot turn that raw JSON into NoulAnswer, ChoiceAnswer, or ScoreAnswer values, because the shape to decode into depends on a field the decoder has not read yet. This page covers how response.go resolves that and what follows from it: preserved unknown answers, deterministic error reporting, and error messages in the API’s vocabulary rather than Go’s.

The shape depends on the type

A noul answer’s wire JSON is {"type": "noul", "noul": 0.8}. A choice answer is {"type": "choice", "choice": "angry", "confidence": 0.9, "probabilities": {...}}. A score answer adds a legend. Each type requires a different set of fields. One Go struct carrying json:"noul,omitempty" alongside json:"choice,omitempty" would decode all three, but an absent noul field would then mean either that the answer is a choice answer or that the response is malformed, and the struct could not distinguish the two.

Two passes: type first, then shape

decodeAnswer in response.go decodes twice. The first pass unmarshals only a header:

var head struct {
    Type string `json:"type"`
}
json.Unmarshal(raw, &head)

encoding/json ignores fields a struct doesn’t declare, so this first pass costs nothing beyond finding "type" and skips every other key in the object. decodeAnswer then switches on head.Type and runs a second, type-specific unmarshal into an anonymous struct shaped for exactly that answer type: a noul object into struct{ Noul *float64 }, a choice object into struct{ Choice *string; Confidence *float64; Probabilities map[string]float64 }, and so on.

Each per-type struct uses pointer fields for its required values. A nil pointer after the second unmarshal means the field was absent from the JSON; a non-nil pointer to 0.0 means the server sent a noul of exactly 0.0, which passes. A nil required field produces a ResponseValidationError naming that field’s path, so a missing probability never decodes silently to zero.

Decoding a type-tagged union in encoding/json takes these two passes, short of guessing at the shape or requiring the caller to pre-declare which type they expect. The first pass determines the shape; the second decodes that shape and validates that it is complete.

UnknownAnswer

The type switch in decodeAnswer has three known cases, noul, choice, and score, plus a default. The default does not fail the decode:

default:
    return UnknownAnswer{Type: head.Type, Raw: append(json.RawMessage(nil), raw...)}, nil

UnknownAnswer stores the wire type and a copy of the exact bytes the server sent. Its MarshalJSON returns Raw unchanged, or a minimal {"type": a.Type} when Raw is empty, which happens for an UnknownAnswer built by hand rather than decoded. An answer of a type this version of the library doesn’t model round-trips byte for byte through a decode and a re-marshal.

A proxy, a logging wrapper, or a cache receives a response, touches a few fields, and passes it along. When the API adds a fourth answer type, an application built against today’s library passes that answer through a marshal/unmarshal cycle unchanged rather than dropping or corrupting it. Failing the decode, or decoding to a zero value and losing the original bytes, would tie every caller’s upgrade path to the server’s release schedule and the library’s.

Deterministic field paths

decodeSystemOne decodes the answers map’s entries in sorted key order, not Go’s randomized map iteration order:

keys := make([]string, 0, len(w.Answers))
for key := range w.Answers {
    keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
    a, err := decodeAnswer(joinPath("answers", key), w.Answers[key])
    ...
}

Decoding stops at the first bad answer it encounters. The fixed iteration order makes that the alphabetically first malformed key on every run. Go randomizes map iteration order by design, so without the sort two runs against the identical malformed response body could report two different field paths. With it, a response whose "a" and "z" answers are both malformed always reports answers.a....

A bug report naming answers.tone.confidence describes the same failure every time it is reproduced, and a test asserting on that field path does not flake with map iteration order.

unmarshalAs

NoulAnswer, ChoiceAnswer, and ScoreAnswer each also implement UnmarshalJSON directly, for callers that already know which type they expect, such as SystemOneAs decoding into a caller-defined struct field typed as typesafe.NoulAnswer. All three route through one generic helper:

func unmarshalAs[T Answer](b []byte, want string) (T, error) {
    var zero T
    a, err := decodeAnswer("", b)
    if err != nil {
        return zero, err
    }
    v, ok := a.(T)
    if !ok {
        return zero, &ResponseValidationError{FieldPath: "type", Err: errors.New("expected " + want + " answer, got " + a.AnswerType())}
    }
    return v, nil
}

unmarshalAs runs the same two-pass decodeAnswer and then asserts the type. A caller that declared a field as NoulAnswer for a question key the server answered with a choice gets expected noul answer, got choice. That message uses the wire vocabulary, "noul" and "choice", not the Go type names typesafe.NoulAnswer and typesafe.ChoiceAnswer. Someone debugging a live API response is reading curl output or a captured request body carrying a "type": "choice" field, and the error names the same token they are looking at. Go type names would need mapping back to the wire type before the error pointed anywhere.

RawResponse

SystemOneResponse.Raw is populated on every successful call, whatever the typed decode did or did not capture:

out.Raw = &RawResponse{Status: resp.StatusCode, Header: resp.Header.Clone(), Body: raw}

RawResponse holds the exact status, headers, and body bytes the server returned, independent of how the typed decode into Answers went. It serves the same purpose as UnknownAnswer: the typed model layers over the wire format without replacing it. A caller that hits a decode limitation the typed API doesn’t handle, such as a field the library doesn’t expose or a response shape from a newer API version, parses Raw.Body directly and makes no second network call.

Related documentation