Which half of a content layer can a compiler check?
Frontmatter typing is two problems wearing one name: a set of values that lives in TypeScript, and a block of YAML that arrives as bytes during a build. Only the first can be checked by a type at all, because a type is erased before the file is read and a .mdx file is never a value the compiler sees.
The content tree I maintain splits along that line. Sixty-six MDX entries sit under content/blog and every one is validated by a parse that runs during the build: required fields, ISO dates, a hard 200-character ceiling on description. Alongside them, six TypeScript files carry ten satisfies assertions covering the tag vocabulary, the product registry, the lab entries and the manifesto. The MDX side can only fail at build time; the TypeScript side fails in the editor, at the column where the mistake is.
A
satisfiesassertion both checks a content literal against a contract and keeps the literal's own type, and that single property is responsible for the precision of the error message and for an optional field being truly absent from one element of a checked collection and present in the next.
What does satisfies keep that an annotation discards?
An annotation replaces the type of the expression with the type you named. const entry: PostMeta = { ... } gives you a PostMeta, so entry.tags is the whole tag vocabulary regardless of what the entry contains and entry.slug is string rather than the slug you typed. satisfies inverts the direction: the contract is a constraint the expression must satisfy, and the variable keeps the type the expression inferred. The declared type is never the type of the value.
That has three consequences that matter in a content layer.
The keys are exact. content/config/lab.ts exports its experiments with satisfies readonly LabExperiment[], so experiments[0].status is 'gathering-data' rather than the declared 'gathering-data' | 'waitlist' | 'in-production'. A misspelled field is an excess-property error instead of a property that quietly does nothing.
The error is local. Write tags: ['System'] in a checked entry and the compiler does not complain about the object, it complains about the string: error TS2820: Type '"System"' is not assignable to type '"AI Systems" | "Model Context Protocol" | "Next.js Architecture" | "Systems"'. Did you mean '"Systems"'? The column it reports is the column where the wrong tag was typed. The further a compiler message sits from the character that is wrong, the more of the file the reader has to hold in their head.
And the contract can be tightened later without editing a single entry. The tag field in the frontmatter contract is currently tags: readonly string[], which checks that the value is an array of strings and nothing about which strings — a free-form tag creates a category page with one entry and a filter nobody can use. Replacing that with a union makes every existing entry checked at once:
/** The tag list is the type: a tag that is not here cannot be written. */
export const tags = [
'AI Systems',
'Model Context Protocol',
'Next.js Architecture',
'Systems',
] as const;
export type Tag = (typeof tags)[number];
export interface PostMeta {
slug: string;
title: string;
excerpt: string;
seoTitle?: string;
tags: readonly Tag[];
}
const featured = {
slug: 'mcp-server-design',
title: 'MCP server design that survives production',
excerpt: 'Tool contracts fail before the models do.',
tags: ['Model Context Protocol', 'Systems'],
} as const satisfies PostMeta;
Because the value is checked and not annotated, the type of featured.tags is readonly ['Model Context Protocol', 'Systems']. It still passes anywhere a readonly Tag[] is expected, and it carries the fact that this entry has exactly two tags from a closed vocabulary. The annotation would have given me the first ability and discarded the second.
Why does a preserved literal make an optional field vanish from one element?
Because a preserved literal has only the properties that were written. PostMeta declares seoTitle?: string; an entry that omits the key has no seoTitle in its inferred type at all. Put several such entries in one array and the element type becomes the union of their written shapes — not a defect in the checker, but the same mechanism that just produced the precise error, applied to a collection.
export const related = [
{
slug: 'mcp-server-design',
title: 'MCP server design that survives production',
excerpt: 'Tool contracts fail before the models do.',
seoTitle: 'MCP server design',
tags: ['Model Context Protocol'],
},
{
slug: 'idempotency-for-agentic-writes',
title: 'Idempotency for agentic writes',
excerpt: 'A retry is not a new intent.',
tags: ['AI Systems'],
},
] as const satisfies readonly PostMeta[];
Both entries satisfy the contract. The first carries seoTitle, the second does not, so related.map((entry) => entry.seoTitle) does not compile:
$ pnpm lint
content/config/related.ts:23:34 - error TS2339: Property 'seoTitle' does not exist on type
'{ readonly slug: "mcp-server-design"; readonly title: "MCP server design that survives
production"; readonly excerpt: "Tool contracts fail before the models do."; readonly
seoTitle: "MCP server design"; readonly tags: readonly ["Model Context Protocol"]; } | { ...; }'.
Property 'seoTitle' does not exist on type '{ readonly slug: "idempotency-for-agentic-writes";
readonly title: "Idempotency for agentic writes"; readonly excerpt: "A retry is not a new
intent."; readonly tags: readonly ["AI Systems"]; }'.
Read the second message twice. seoTitle is optional, but optional describes the contract, not the value: the second element's type has no such property, so reading it is not string | undefined, it is an error, and the compiler names the element it means.
There is a wrinkle here that hides the problem and then reveals it. Without as const, TypeScript normalises the array literal's element type by adding a synthetic member to the shape that omitted the key, so the inferred type is { ...; seoTitle: string } | { ...; seoTitle?: undefined } and the read compiles, returning string | undefined. Add as const — which is what you add to obtain literal shapes — and that normalisation stops. The type becomes a tuple of precisely what you wrote, so the failure arrives on the day you tighten a declaration, in a file nobody edited.
How do you get one element type without giving up the check?
Keep the literal where it is validated, and cross the boundary once, at the point where the collection stops being a declaration and starts being data. That point is a function whose parameter type is the interface, because inside its body the element type is uniform:
/** Collapses a checked literal table into one element type for consumers. */
export function toRelatedPosts(entries: readonly PostMeta[]): readonly PostMeta[] {
return entries.map((entry) => ({
...entry,
seoTitle: entry.seoTitle ?? entry.title,
}));
}
const posts = toRelatedPosts(related);
const head = posts[0]?.seoTitle;
const summary = posts.map((post) => `${post.seoTitle ?? post.title} — ${post.excerpt}`);
The call accepts the as const satisfies value, because it was assignable to readonly PostMeta[] or it would not have compiled in the first place, and it returns a collection where seoTitle exists on every element as string | undefined. The default fills it, so downstream code reads a string. Nothing about the check changed; only what a reader of the collection sees.
Typing the consumer's parameter by the interface rather than by the literal has a second effect that is easy to miss. The inferred type of a checked literal contains only the values actually written, so a consumer typed (typeof related)[number]['status'] cannot switch on a status no entry currently uses: that case is itself a compile error, TS2678, not comparable to the type. With the declared interface as the parameter type, the same switch is checked against the contract and a missing case is reported. The contract belongs to the consumer; the literal belongs to the data.
| How the registry is declared | What the compiler checks | Type you keep | What it costs |
|---|---|---|---|
const entry: PostMeta = { ... } | Assignability only | PostMeta, widened | Literal intent gone; entry.tags is the whole vocabulary |
const entry = { ... } satisfies PostMeta | Assignability, error on the value | Inferred literal, contextually widened | Absent optional keys are normalised to ?: undefined, hiding the absence |
const entry = { ... } as const satisfies PostMeta | Assignability, error on the value | Readonly literal, tuple arrays, literal strings | An absent optional key is genuinely absent, so collections become unions |
const related: readonly PostMeta[] = [ ... ] | Assignability only | One element type | Literal shapes dropped; consumers must take the interface, not the literal |
What can no type system see at this boundary?
Everything above concerns values written in TypeScript, and frontmatter is not one of those. gray-matter returns data as an untyped record, YAML can put a Date object where you expect a string, and tags can arrive as a single string when someone writes tags: Systems without brackets. The most common mistake I have watched here is a team adding as PostFrontmatter to the parse result and concluding that frontmatter is typed. It is a cast: it instructs the compiler to stop looking, and its strength is the promise that the YAML is well formed, which is the thing in question.
So the two halves need different instruments — a parse that narrows and throws for the bytes, satisfies for the literals in code. Where I get lazy is the parse, because keeping content in the same repository as the code that renders it is what makes the compile-time half possible at all: a registry that lives in a CMS or behind a content API is a network call at build time, and no compiler sees any of it.
| Where a content value lives | What the compiler can check | What it cannot | When a defect surfaces |
|---|---|---|---|
| TypeScript literal in the repository | Field names, literal unions, tag vocabulary, required fields | Anything computed at runtime | In the editor and in tsc --noEmit |
| MDX frontmatter in the repository | Nothing; the block is a string until parsed | Types, formats, lengths | At the build, if a parse validates it |
| Markdown in a CMS or content API | Nothing | All of the above, plus reachability | After deploy, when a reader or a crawler notices |
When is this the wrong approach?
It is the wrong approach as soon as the values are not authored in the repository. If the tag list lives in the CMS, satisfies has no subject: you are asserting a type against data you cannot see, and the assertion belongs in a runtime schema instead. If you already parse with a schema library, z.infer gives you the type from the same declaration that validates the bytes, and re-declaring that contract as an interface creates two places to keep in step. One owner per contract.
It is also wrong for a genuinely heterogeneous collection. The mistake in the example above is not the union, it is that related is a homogeneous list whose members happen to differ. A table where different rows carry different payloads is a discriminated union and should be modelled as one, with a kind field the compiler narrows on, plus an assertNever in the default branch. Flattening that into one interface with every field optional and a default filled in at the boundary destroys the information the union was carrying, and then the compiler cannot tell you which fields a given row is allowed to have.
as const has costs worth stating. Readonly arrays are not assignable to parameters typed string[] in third-party APIs, so a literal table passed into a library that mutates its input needs a copy or a cast, and that friction tends to spread. Literal types also make messages long: the error above printed the element type twice. On a table of forty entries the same failure is a screenful of type names, which is worse than a one-line error about a widened type, so as const is worth it only where the literal types are actually read.
The last cost is the team. satisfies arrived in TypeScript 4.9, in November 2022, and as const satisfies reads as noise to someone who has not used it. Where a table carries no literal unions and no optional keys, a plain annotation is cheaper to maintain and produces a shorter error, and picking it is not a compromise.
What should you change first?
Take the one collection in your content layer that has an optional field and is read by index or mapped, and look at how it is declared; if it uses as const satisfies, add the boundary function above and leave everything else alone. Then spend the effort where a literal union does the most work, which in my case was the tag vocabulary, since a tag outside the set is the defect with the widest blast radius: a category page with one entry, a filter that leads nowhere, and a related-posts list that never fills. Finally, write out the switch you actually rely on and read what the compiler says: if a case is flagged as not comparable, the consumer is typed by the data rather than by the contract. The test is not whether the file compiles, it is whether a wrong value fails in the editor at the character that is wrong.
Keep reading
- GEO for Engineers: Making Your Site Legible to Language Models2026-03-216 minEngineering
- Server Actions vs API Routes: The Caller Decides, Not the Operation2026-02-198 minEngineering
- MDX Content Architecture: A Malformed Entry Should Fail the Build, Not the Page2026-02-048 minEngineering