Knowledge base · Essay

Why your content should come with its own types

90Ø120 pitchØ60 pitchAB
Fig. 01 Spur wheel and pinionThirty teeth to fifteen: the wheel turns once for every two turns of its pinion.
Kind
Essay
Published
2 Sep 2026
Reading
9 min
Centres on
coho tier create qa2

Applies to: every Coho project, in TypeScript, C#, Java, Kotlin, and Python

You will learn: why Coho generates a typed library from your own content model, what that catches before your site runs, how it compares with the usual ways of reading content, and how releases close the loop on the content side


The short version

Your content model is a schema. Your code depends on it as much as on any database table or API contract, so it deserves the same tools you use for those: a compiler that knows its shape, an editor that completes its field names, and a build that fails when the two stop matching.

Coho generates that for you. From your project's content types, it writes a library in your own language: one type per content type, and a client whose reads return them. A field you misspell, a field someone removed, a sort the type does not allow, a localized value you forgot can be missing: each of these becomes an error in your editor or your build, not a blank space on a live page.

The goal is simple. Find as much as possible at build time, in your code and in your content, so that what reaches production has already been checked.


How content is usually read

Every team that reads content from a headless CMS settles on one of a few approaches. Each one works. Each one moves some of the checking to runtime, where it is found by a visitor instead of a build.

Untyped JSON. You call the API, or a generic client, and get back a dictionary. Field names are strings: entry.fields["publishedAt"]. Nothing knows whether publishedAt exists, whether it is a date, or whether it is always there. A typo reads as missing, a removed field reads as missing, and missing renders as nothing. You find out when someone looks at the page.

Hand-written models. You write an interface or a class for each type, mirroring the schema, and cast the JSON to it. It reads well, and it is a promise nobody checks. When the model changes, the code still compiles, because the classes describe the schema as it was when someone last updated them. The cast says the data has a shape; nothing makes it so.

Runtime validation. You write a schema in a validation library and parse every response through it. This is honest: bad data fails loudly. But it fails at runtime, the schema is a second copy of your content model to keep in step by hand, and it tells you what went wrong only once a request has gone wrong.

A query language with its own code generation. You write queries, and a generator types each query's result. You get types, at the cost of a query file for every shape you read, a toolchain to learn, and types that describe your queries rather than your content. Whether the schema behind them is the one your site will read in production depends on which endpoint you generated from, and when.

Types for one language. Some tools generate type definitions from a content model, most often for TypeScript only. That is a real improvement, and it stops at the language boundary. A .NET service, a Java backend, or a Python pipeline reading the same content is back to dictionaries or hand-written models. And types alone leave the client generic: you still pass a type name as a string and tell the compiler what comes back.


A library made from your own model

Coho's code generator reads your project's type model and writes a content library: the package your sites and services install to read your content.

coho-codegen pull        # the type model for the configured ref
coho-codegen generate    # a type per content type, and the client
coho-codegen check       # in CI: fails if the code is out of date

What it writes is not a set of loose types beside a generic client. The client is generated too, so a read names its type and returns it:

const post = await coho.blogPost.bySlug("hello-world", { depth: 2 });
const latest = await coho.blogPost.list({ order: "-publishedAt", limit: 3 });

post is a BlogPost. Its fields are the model's fields, with the model's types. Your editor completes them, and your compiler checks every use.

What the compiler now catches

  • A misspelled field is a compile error, not an undefined.
  • A removed or renamed field breaks the build at every place that used it, so the change and its consequences show up together, in review.
  • A sort the type cannot do does not compile. order accepts only the fields the type can be sorted by, so an unsortable order is caught in your editor rather than refused by the server.
  • A field that can be missing is typed as optional. Delivery leaves out a localized field that has no value in the requested locale, so every localized field is optional, and the compiler makes you handle the empty case before a visitor finds it.
  • A reference is typed by what it points at. A link to an author is a Linked<TeamMember>: the entry, or a stub that says why it is not there (not followed this deep, or not on this release). You check which, and the compiler knows the type on the other side.
  • A repeater holds typed items. A page's sections are a list of Hero | Quote | Gallery, and you switch on the item's type to narrow it. Items can hold items, typed at every level.
  • A singleton has one getter. coho.siteSettings.get() reads the one entry, typed, with no slug to remember.

What it deliberately does not break

Strong types are only useful if they do not make your build fragile, so the generated code is strict where it protects you and open where strictness would only get in the way:

  • A choice accepts values added later. If an editor adds a new value to a choice field, your existing code still compiles and still reads it. You handle the new value when you choose to.
  • An item of a component added after generation is left out and reported, never typed as something it is not.
  • A field your code does not know about is ignored. Adding a field to the model never breaks an older build.

In your language, in your language's terms

The same model generates the same library in each supported language, written the way that language is usually written:

LanguageWhat a content type becomes
TypeScriptAn interface, with unions for families of types and for repeater items
C# (.NET 8 and 10)A record, with derived records for types that extend others, and AddCoho for ASP.NET Core dependency injection
Java (17 and later)A class with getters, marked @Nullable where delivery can leave a field out
KotlinA class with properties, nullable where a field can be missing
Python (3.11 and later)A Pydantic model with Python field names, choices as Literals, ready for FastAPI to return as it is

Type inheritance and traits come through in each language's own way: derived records in C#, a family union such as AnyPage in TypeScript, and traits as interfaces or protocols that any type with the trait implements. Code written against Seo accepts an entry of any type that has the trait.

Go is on the roadmap.

Every delivery SDK is tested against the same recorded responses, produced by the Coho server. So a site in TypeScript and a service in Java reading the same release see the same content, the same missing fields, and the same unresolved links, typed the same way.


The model and the code stay in step

A generated library is only as good as its match with the model. Coho keeps the two together in three ways.

The generated code is committed. generate writes ordinary source files, which you commit with the type model they came from. A change to the content model then shows up as a code diff in review: a new field, a removed one, a type that now extends another.

CI catches code that is out of date. coho-codegen check fails the build when the committed code differs from what the committed model would generate. It never calls the server, so it is fast and needs no credentials.

Generate from a release. A library generated from a tag describes exactly the content of that tag, and records which tag it came from. Generate from the release you are about to promote, and the types your code compiles against were made from the same release your site will read. A branch's types can still change, so the generator warns when the model came from one.


Content is checked before release, too

Types make your code honest about the content. The other half is making the content honest about its types, and Coho checks that before a release as well:

  • Every save is validated against its type as it stands on that branch.
  • Every merge is validated against the result. When a merge changes a type, every entry of that type is checked against the merged version, including entries the branch never touched, and the merge is refused if any would be invalid.
  • A release can be validated as a whole. One gap remains: a type changed directly on a version branch does not re-check the entries already there. Tagging with --validate closes it:
coho tag create v1.4.0 --branch v0.0.x -m "Spring catalogue" --validate

Every entry at the tag point is checked against its type and its links. If any fail, nothing is tagged, and the command lists them. Add it to your release script and a release that would mislead your types never exists.

Put together, a typo in your code fails the compiler, a stale library fails CI, and content that does not match its type fails the merge or the release. By the time a visitor asks for a page, everything that could be checked ahead of time has been.


Common questions

Do I have to use the generated library? No. The delivery API is plain JSON over HTTPS, and you can call it from anything. The generated library is the path we recommend, because it moves the checks from runtime to build time.

Does the generated library call Coho when it builds? Only pull does, to fetch the type model, and it needs a read-only project token. generate and check work from the committed model, offline. Your sites never need that token: they read with a delivery key, passed in by the app.

What happens when the model changes and I have not regenerated? Your site keeps working. Fields your code does not know are ignored, new choice values still read, and items of components it has not seen are left out and reported. When you regenerate, the compiler shows you everywhere the change matters.

Can one content library serve several sites? Yes. That is what it is for. Publish it as a package, and every site and service that installs it reads the same content with the same types.