All posts
Engineering

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.

HyperJobs Team6 min read

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.

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.

  1. Board publishes

    a list of employment types

  2. Stored as TEXT

    encoded once

  3. Returned verbatim

    "[\"FULL_TIME\"]"

  4. JSON.parse

    in every client, forever

The draft's round trip. The encoding went in once and was never undone.

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 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 matches a value inside the list, which is why FULL_TIME finds both ["FULL_TIME"] and ["FULL_TIME","TEMPORARY"]. And since commas mean OR, ?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:

FieldYou sendYou 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, 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.

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.

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.