Field guideEngineering

2026 · EngineeringAbout 14 min readNovus Stream Solutions

Type-safe content: how a DocSlug union and structured sections stop broken posts

Two small type decisions — a union type for documentation slugs and a structured (non-string) body format — turn whole classes of content bugs into compile errors on novusstreamsolutions.com. A concrete look at content guardrails.

A union type constraining doc links and a structured section schema rejecting malformed posts at compile time

Overview

The promise of code-as-content is that the compiler reviews your posts, but a compiler can only enforce what the types describe. The leverage comes from designing the content types so that the things you want to be impossible actually are. On novusstreamsolutions.com, two small type decisions do an outsized amount of work: documentation links are constrained to a union type of the slugs that exist, and post bodies are structured data rather than freeform strings. Each one converts a common, annoying class of content bug into a compile error — a failure that happens on the developer's machine before anything ships, rather than a broken page a reader finds later. This post is a concrete look at how those guardrails are built and why they matter more than they look.

The general principle behind both is "make the bad state unrepresentable." Instead of allowing a broken value and then trying to detect it, you design the type so the broken value cannot be written in the first place. It is the same instinct that, on the engineering side, leads to fixing a bug class structurally rather than patching each instance — and it applies just as well to content as to code.

Why structured sections beat a string of HTML

The second decision is how the body of a post is stored. The tempting approach is a single field holding a blob of HTML or markdown — flexible, familiar, and a magnet for problems. A raw-string body can contain malformed markup, inconsistent heading structure, broken inline elements, and styling that drifts from post to post, and none of that is visible until the page renders. The body on this site is instead structured data: an array of sections, where each section is an object with an optional heading, a list of paragraphs, an optional list, and an optional image with its own required fields. The body is not text to be parsed; it is data with a shape the compiler understands.

Structuring the body this way buys consistency and safety at once. Every post is rendered by the same component reading the same structure, so headings, spacing, lists, and images look identical across the entire blog without any per-post styling discipline — the consistency is structural rather than maintained by hand. An image that is missing its alt text or its dimensions is a compile error, not an accessibility problem discovered in an audit, because those fields are required on the image type. A section cannot be subtly malformed, because there is no markup to malform — there is only data that either fits the shape or does not. The body format makes a whole category of "the post renders wrong" bugs impossible to express.

Make the bad state unrepresentable

The principle behind both type decisions deserves to be stated as the general technique it is, because it applies far beyond content: make the bad state unrepresentable rather than detectable. The weak approach to data integrity is to allow any value and then write validation that checks for bad ones — which means the bad value can exist, can be written, and is only caught if your validation is thorough and runs at the right time. The strong approach is to design the type so the bad value cannot be expressed at all, so there is nothing to validate because the wrong thing simply cannot be written. A union of valid slugs does not detect a bad slug; it makes a bad slug a value that does not typecheck.

This distinction matters because detection is fallible in ways prevention is not. Validation has to be written, has to be remembered, has to run, and can have gaps; a type that forbids the bad state has none of those failure modes, because the prevention is structural and automatic. Every place that uses the type inherits the guarantee for free, with no validation code to maintain. This is the same instinct that, on the engineering side, leads to fixing a bug class structurally rather than patching each occurrence — and it is the highest-leverage form of correctness, because it converts "we must remember to check this everywhere" into "this cannot be wrong." Designing types so the bad state is unrepresentable is the deepest version of letting the compiler do your quality control.

Required fields as a forcing function

A quiet but powerful aspect of the typed-post approach is that required fields act as a forcing function, ensuring that every post has the things a good post needs because it cannot exist without them. When the post type requires a title, an excerpt, a date, and a structured body, a post missing any of those does not render with a gap — it fails to compile, which means the author is forced to provide the missing piece before the post can ship. The type encodes a definition of a complete post, and the compiler enforces that definition on every single post without exception, so completeness is guaranteed rather than hoped for.

This is especially valuable for the fields that are easy to skip but matter, like the excerpt that drives the share card and the alt text required on every image. In a system where these are optional, they get omitted under time pressure, and the omissions accumulate into a content property full of missing descriptions and inaccessible images. Making them required fields means they cannot be skipped — the post will not build without them — so the quality floor is enforced structurally rather than depending on the author's diligence on every post. The required fields turn good practices that are easy to forget into preconditions that are impossible to omit, which for a solo operator with no editorial checklist is exactly the kind of automatic enforcement that keeps quality consistent across a large and growing body of work.

The branded slug as a single source of truth

The union type for doc links is one instance of a broader pattern worth drawing out: deriving the set of valid values from a single source of truth so that the valid set and the references to it can never disagree. The valid slugs are not a list maintained separately from the actual docs and hoped to match; they are derived from the real set of documentation pages, so the type of "a valid doc link" is automatically exactly the set of docs that exist. There is no second copy to keep in sync, which means there is no opportunity for the copy to drift from reality — the type is the reality, expressed as a constraint.

This single-source-of-truth design is what makes the guarantee durable rather than a snapshot. When a doc is added, it becomes a valid link target automatically; when one is removed, references to it immediately fail to compile, pointing at exactly the posts that need updating. The type tracks the real state of the docs continuously, so the relationship between posts and docs stays correct as both change, with the compiler flagging any reference that no longer resolves. This is the same instinct as deriving the share image from the page data rather than maintaining a copy: anywhere you can derive a constraint from the real data instead of maintaining a parallel list, you remove the drift that parallel lists inevitably develop. The valid-slug set derived from the real docs is correctness that maintains itself.

Catching errors at author time, not runtime

A subtle but important property of type-checked content is when the error is caught: at author time, in the editor, as the post is being written, rather than at runtime when a reader hits the page. As soon as a post is missing a required field or references an invalid slug, the editor flags it, often before the author has even finished, so the mistake is corrected in the moment of writing rather than discovered later. This tightens the feedback loop on content errors to nearly instant, which is the shortest and cheapest place to catch any mistake — the moment it is made, by the person making it, with full context.

This author-time feedback is qualitatively better than the alternatives. Runtime errors are caught by readers, which is the worst place — public, embarrassing, and after the fact. A separate validation pass catches them at build time, which is better but still after the writing is done and the context is fading. Type checking in the editor catches them during authoring, when fixing them is trivial because the author is right there with the relevant knowledge. The error never even reaches a commit, let alone a build or a reader. For content quality, moving the catch point all the way back to the moment of authoring is the ideal, and typed content delivers exactly that, turning content correctness into immediate editor feedback rather than a downstream discovery.

The types document the content model

Beyond enforcement, the content types serve as living documentation of what a post actually is, which is valuable for a content model that grows and is maintained over time. The post type spells out precisely what fields a post has, which are required and which optional, and what shape each takes — so anyone (including the author months later) can read the type and know exactly what constitutes a valid post, without guessing or reverse-engineering from examples. The type is an authoritative, always-current specification of the content model, because it is the thing the system actually enforces rather than a separate document that could drift from reality.

This self-documenting quality matters most when the content model evolves or when the operator returns to it after time away. A CMS's content model lives in its configuration and conventions, often partly in someone's memory; a typed content model lives in the types, which are precise, versioned, and impossible to be out of date because they are what the compiler enforces. Adding a field, making one required, or introducing a new section type is a change to the types that is immediately visible and immediately enforced everywhere. The types are simultaneously the specification, the enforcement, and the documentation of the content model, collapsed into one artifact, which means there is no separate documentation to maintain and no possibility of the documentation being wrong. The content model documents itself by being expressed as the types that govern it.

Evolving the schema without breaking posts

A real test of any content model is how safely it can change as needs evolve, and typed content handles schema evolution with a safety that loose content does not. When a field is added, made required, or restructured, the compiler immediately identifies every post that no longer conforms, turning a potentially risky migration into a guided checklist: the build fails with a precise list of exactly which posts need updating and why. There is no possibility of silently leaving some posts in an old, now-invalid shape, because the type change is enforced uniformly and the non-conforming posts cannot compile until they are brought into line.

This makes evolving the content model a controlled operation rather than a gamble. In a loosely-structured system, changing the expected shape of content means hoping you find and update every affected item, with stragglers surfacing later as broken pages. With typed content, the compiler is the migration tool — it will not let the change ship until every post conforms, so a schema evolution is complete and correct by the time it builds, not eventually-consistent and hopefully-complete. For a content property meant to grow and adapt over years, the ability to change the underlying model with the compiler guaranteeing that nothing was left behind is what makes evolution safe enough to actually do, rather than something avoided because the migration risk is too high. The types turn schema changes from risky sweeps into compiler-verified migrations.

Why this beats runtime validation

It is worth contrasting type-checked content with the more common approach of runtime validation, because they are not equivalent and the difference favors types decisively for this use. Runtime validation checks content as it is processed or served, which means the invalid content can exist, can be committed, and is only caught when the validation happens to run against it — and validation code has to be written, maintained, and kept comprehensive, with any gap letting bad content through. It is detection after the fact, dependent on the thoroughness and timing of the checks. Type checking, by contrast, prevents the invalid content from existing in the first place, with the guarantee enforced by the compiler on every build automatically.

The practical superiority is that type checking has fewer failure modes and zero maintenance burden. There is no validation code to write or keep in sync with the content model, because the types are the content model; there is no question of whether the check ran, because compilation always runs; there is no gap for bad content to slip through, because the bad state cannot be expressed. Runtime validation is a layer you build and maintain that catches some bad content some of the time; type checking is a property of the language that catches all expressible-as-invalid content all of the time, for free. For content correctness, preventing the invalid state structurally is simply a stronger guarantee than detecting it dynamically, which is why the typed approach is not just a stylistic preference but a genuinely more reliable way to keep a large content corpus correct.

The payoff: bad content fails the build

The combined effect of these decisions is that the build becomes a real quality gate for content, not just for code. A post with a missing required field, a doc link to a nonexistent page, an image without alt text, or a malformed section does not produce a degraded page that ships and disappoints a reader — it fails to compile, and the site does not deploy until it is fixed. The failure is loud, early, and located: the compiler names the file and the problem. For a solo operation with no separate QA pass on content, moving these failures from "a reader finds it in production" to "the build refuses it" is the difference between a content property that slowly accumulates broken pages and one that cannot.

It is worth appreciating how little code this takes. A union type for the doc slugs is a few lines. A structured section type is a small interface. Yet together they eliminate broken doc links, missing fields, inconsistent rendering, and inaccessible images as categories of bug — not by adding a validation layer that has to be maintained, but by describing the content precisely enough that the wrong thing cannot be written. That is the highest-value kind of guardrail: cheap to build, impossible to forget, and enforced automatically by tooling you are already running. The companion post on code-as-content covers the architecture that makes this possible, and the edge OG image post shows the typed post data driving yet another derived artifact for free.