# The JSON string that never shipped

> employment_type almost went live as JSON text inside JSON. The post explaining why we couldn't fix it is what convinced us to fix it.

Source: https://hyperjobs.io/blog/the-json-string-in-your-json

*HyperJobs Team · 21 April 2026 · 6 min read*

<Note>
  Revised 2026-07-16 for the v1 launch. This post originally ran as *"The JSON
  string in your JSON"*, documenting a field that came back as the text of an
  array — and arguing, at length, that our own compatibility policy forbade
  fixing it. The draft it described never shipped. This is the story of the
  argument losing.
</Note>

`job.employment_type === "FULL_TIME"` was always false. Not usually. Always —
including on every one of the four-hundred-thousand-plus postings that are
full-time.

That was the payload a pre-release draft of this API served. The comparison
didn't throw and didn't warn; it returned `false`, your `if` took the other
branch, and the bug shipped. Here's what the object looked like:

```json
{
  "employment_type": "[\"FULL_TIME\"]",
  "education_requirements": "[\"bachelor degree\"]",
  "skills": ["typescript", "graphql", "postgresql"],
  "taxonomies": ["Software"]
}
```

Four fields, all lists of tokens. Two arrays, and two strings containing the
*text of* an array. There was no principle separating them — not size, not
nullability, not versioning. `skills` and `taxonomies` got parsed on the way
out; the other two didn't, and nobody noticed before the shape reached the
documentation.

<Flow
  caption="The draft's round trip. The encoding went in once and was never undone."
  steps={[
    { label: "Board publishes", note: "a list of employment types" },
    { label: "Stored as TEXT", note: "encoded once" },
    { label: "Returned verbatim", note: '"[\\"FULL_TIME\\"]"', accent: true },
    { label: "JSON.parse", note: "in every client, forever" },
  ]}
/>

The shipped v1 returns what the draft should have:

```json
{
  "employment_type": ["FULL_TIME"],
  "education_requirements": ["bachelor degree"],
  "benefits": ["Equity", "Health, dental & vision"],
  "skills": ["typescript", "graphql", "postgresql"],
  "taxonomies": ["Software"]
}
```

Every list field is an array — `benefits` included, which the draft served as
prose. `job.employment_type.includes("FULL_TIME")` works with no decode step,
no boundary function, no `parseList` helper copied between projects. If you
kept a `JSON.parse` from the draft-era docs, delete it: parsing an array throws.

## Why we almost didn't fix it

The original version of this post made a case we believed while writing it, and
it's worth restating because the reasoning was correct — only the premise was
wrong.

The [compatibility policy](/docs/changelog) says changing a field's type is a
breaking change, and breaking changes don't happen inside `/v1`. A client that
read the docs and wrote `JSON.parse(job.employment_type)` was *correct*, and
"fixing" the field breaks exactly the people who did the reading — loudly,
since `JSON.parse` on an array is a `TypeError`, not a graceful degrade. So the
post concluded: the field is ugly, the promise matters more, `employment_type`
is a string for the life of `/v1`.

All of that holds — *if anyone has shipped against the field*. Nobody had. The
draft had documentation and zero production consumers, which meant the policy
wasn't yet protecting anyone; it was only protecting the bug. A compatibility
promise is a contract with your users, not a suicide pact with your own typos,
and the day before you have users is the last day you can say so. We fixed it,
and the policy now protects the *right* shape from the launch forward.

The lesson we kept: the draft-era docs said, in bold, "write your client as
though it is permanent." Documentation that has to talk like that is a code
review comment addressed to the wrong audience.

## It really is a list

The part of the original post that survives untouched, because it was never
about the encoding.

A posting can carry several employment types at once —
`["FULL_TIME","TEMPORARY"]` is a full-time contract role — and **20+
combinations occur in the data**. The [`?employment_type=`
filter](/docs/api-reference/jobs-list) matches a value *inside* the list, which
is why `FULL_TIME` finds both `["FULL_TIME"]` and `["FULL_TIME","TEMPORARY"]`.
And since [commas mean OR](/docs/guides/filtering),
`?employment_type=CONTRACTOR,TEMPORARY` is the union — postings carrying
either.

The array was always the honest shape. That's what made serving its
`JSON.stringify` so grating: you weren't unwrapping ceremony to reach a string,
you were recovering a list that was a list all along.

## What you send is not what you read back

Two asymmetries from the draft era survive, both worth knowing:

| Field | You send | You read back |
| --- | --- | --- |
| `employment_type` | `?employment_type=FULL_TIME` | `["FULL_TIME"]` |
| `education_requirements` | `?education=bachelor degree` | `["bachelor degree"]` |

You filter with a bare value and read back an array — the filter tests
membership, not equality. And the parameter for the second field is
`?education=`, not `?education_requirements=` — it filters the field of the
longer name, but it isn't called that.

The vocabularies still have opposite conventions sitting side by side:
`employment_type` is `SCREAMING_SNAKE_CASE` (`FULL_TIME`, `PART_TIME`,
`CONTRACTOR`, `TEMPORARY`, `INTERN`, `PER_DIEM`, `VOLUNTEER`, `OTHER`);
`education` is lower-case with spaces (`bachelor degree`, `high school`,
`postgraduate degree`, `professional certificate`, `associate degree`). The
difference is what happens when you mix them up. The draft returned zero rows
and let you conclude nobody's hiring; v1 returns a
[`400` with the allowed values in the hint](/docs/errors), because both fields
are closed vocabularies and the server knows them.

## The flip side of the same policy

This half of the original post is unchanged, because it was right the first
time.

<Warning title="New enum values ship at any time, and they are not breaking">
  New `taxonomies`, new `source` values as ATS integrations land, new
  `data_tier` values — all **backwards-compatible** by policy, and they can
  appear without notice. So can new fields on the Job object.

  A client with an exhaustive `switch` that throws on the default case will
  break on a change that is, by policy, not breaking. That's not a bug in the
  policy. It's a bug in the client.

  Don't fail on an unknown enum value or an unrecognised field. Log it, skip
  it, render it as-is — anything but throw.
</Warning>

Read the two halves together and they're one rule seen from both ends: **the
types are frozen so you can trust them, and the values are open so you can't
enumerate them.** v1's launch shape is the shape for the life of `/v1` — that
promise is now load-bearing, because this time there are clients on the other
end of it. Parse against the type. Never assume the value set is closed.

## While you're in there

The JSON-in-JSON is gone, but the payload still has shapes worth a second look.
Three quick ones:

- **`description` is absent, not null**, unless you pass
  `?description_format=text` or `html`. So `"description" in job` is `false`
  and `job.description` is `undefined`.
- **`location` is never null**, because `remote` always resolves to a real
  boolean. `if (job.location)` is always true and tells you nothing — test
  `job.location.countries?.length` instead.
- **`openings` is absent** unless you pass `?collapse=true`.

And the null rule that outranks all of them: `education_requirements: null`
means the posting *said nothing* — not "no degree required". A missing
classification is silence, not a negative.

Field-by-field types and nullability live in
[the Job object](/docs/objects/job).
