Types Are Memory for Your Codebase
How TypeScript, schemas, and explicit interfaces act as durable context for both humans and agents.
Every meaningful codebase depends on shared memory. Humans remember what a function returns, agents infer what a payload probably contains, and teams hope that yesterday's assumption still holds when tomorrow's feature lands.
Types make that memory durable. They move expectations out of chat history, ticket comments, one-off migration notes, and individual developer heads into somewhere the codebase can actually enforce.
This matters most in AI-built applications, where teams tend to accumulate more written context than executable context. A prompt says one thing, a README says another, a script preserves an older assumption, and the running application follows whichever version still happens to be wired in.
I ran into a clear example of this during a codebase review. The project docs described a Markdown-first data model, but the running system had quietly made PostgreSQL the real source of truth. A separate sync path read only YAML frontmatter into the dashboard, while structured tables in the Markdown body were invisible to the importer. There was no single dramatic bug. Instead, body data, frontmatter, database rows, dashboard rendering, and scoring logic each believed slightly different things — and nobody had a reliable way to tell which version was correct.
That is a type problem before it is a cleanup problem. The system lacked a contract that said, in executable form, "this is the canonical source, this is the shape consumers can rely on, and here is what happens when the shape is wrong."
Types Remember Product Decisions
TypeScript is usually introduced as a correctness tool, and that framing is true — but it undersells what types do in a growing codebase. Types are also a memory system. They record which states are legal, whether a score can be missing, whether a profile came from a database row or an external API, and whether a given field is safe to display or still needs validation.
Documentation captures intent for humans. Types force the code to stay consistent with that intent every time it compiles. When a product decision changes — "scores are no longer optional" or "profiles can also come from a webhook now" — a type change propagates the update to every file that touches the affected data. Documentation has to hope someone reads it.
This is where agents get the most benefit. A human developer might remember that "pipeline profiles come from the database now." A coding agent starting a fresh session cannot. It reads local context, pattern-matches against existing code, and produces the next plausible patch. If the repository has no durable contract, the agent re-infers the rule on every run. But if the rule is encoded as a type, a schema, and a CI check, the agent gets compile-time feedback the moment it drifts.
Name the Source of Truth
When two stores contain overlapping data, "source of truth" can't stay as a sentence in a planning doc. It needs to show up in the code.
A profile imported from frontmatter is not the same thing as a profile read from the canonical database row. They share fields, but they do not carry the same authority. A discriminated union makes that difference explicit:
type ProfileSource =
| {
kind: 'database';
profileId: string;
profileData: CanonicalProfile;
updatedAt: string;
}
| {
kind: 'frontmatterImport';
markdownPath: string;
importedFields: FrontmatterProfile;
importedAt: string;
};
type CanonicalProfile = {
displayName: string;
stage: PipelineStage;
score: number | null;
};
type FrontmatterProfile = {
displayName: string;
stage?: PipelineStage;
};
type PipelineStage =
| 'identified'
| 'outreachSent'
| 'meetingScheduled'
| 'diligence'
| 'closedWon'
| 'killed';
function profileLabel(source: ProfileSource): string {
switch (source.kind) {
case 'database':
return source.profileData.displayName;
case 'frontmatterImport':
return source.importedFields.displayName;
default: {
const unreachable: never = source;
return unreachable;
}
}
}The specific names here don't matter — pick whatever fits your domain. What
matters is that the source-of-truth decision has moved from tribal knowledge
into the compiler. The
TypeScript Handbook
calls this pattern discriminated unions: when every member of a union has a
common property with literal values, TypeScript narrows the type inside each
branch. The never default turns future drift into a compile error. If someone
later adds kind: 'apiImport', every exhaustive switch lights up as work that
needs handling.
A future patch can't quietly pretend all profile sources are interchangeable. That is what "types as memory" looks like in practice.
Treat Boundaries as Unknown
Static types don't validate runtime data. They describe what code may assume
after data has crossed a trust boundary. The boundary itself needs to start as
unknown, not as a hopeful interface cast.
This comes up constantly: request bodies, webhooks, queue messages, JSON columns, Markdown parsers, CSV imports, AI-generated extraction output. Any time data enters your system from outside, the safe pattern is the same:
- Accept
unknowninput. - Parse it through a runtime schema.
- Work only with the parsed, typed output.
Here is the frontmatter-import problem expressed with Zod:
import { z } from 'zod';
const PipelineStageSchema = z.enum([
'identified',
'outreachSent',
'meetingScheduled',
'diligence',
'closedWon',
'killed',
]);
const FrontmatterProfileSchema = z.strictObject({
displayName: z.string().min(1),
stage: PipelineStageSchema.optional(),
hotTakes: z.array(z.string()).default([]),
deployReadiness: z
.strictObject({
score: z.number().int().min(0).max(5),
evidence: z.array(z.string()),
})
.optional(),
});
type FrontmatterProfile = z.infer<typeof FrontmatterProfileSchema>;
export function parseFrontmatterProfile(input: unknown): FrontmatterProfile {
const result = FrontmatterProfileSchema.safeParse(input);
if (!result.success) {
const paths = result.error.issues.map((issue) =>
issue.path.length === 0 ? 'root' : issue.path.join('.'),
);
throw new Error(`Invalid profile frontmatter: ${paths.join(', ')}`);
}
return result.data;
}The unknown parameter is doing real work here. It stops application code from
touching the value before the parser has verified its shape.
Zod schemas map directly to TypeScript's type system — you
get parse, safeParse, and inferred types via z.infer, and strict object
schemas flag unexpected keys instead of silently passing them through.
For a coding agent, this is a much better guardrail than a prompt that says "be
careful with frontmatter." The next generated importer either calls
parseFrontmatterProfile or gets flagged in review for bypassing the boundary.
The next scoring change either handles deployReadiness as the optional parsed
field it is, or the compiler tells the agent it missed a case. The schema
doubles as documentation that actually runs.
Start With an Unsafe-Cast Inventory
The practical starting point is not "make the whole app type-safe." It is "find the places where the codebase has asked TypeScript to stop remembering." In most repos, that inventory takes less than an hour:
rg -n \
"\bas\s+any\b|as\s+unknown\s+as|@ts-ignore|@ts-expect-error|JSON\.parse\(" \
src functions workerDo not treat every match as equally bad. A narrow @ts-expect-error around a
third-party type bug is different from casting a webhook body to any before it
touches billing, permissions, or customer records. The point of the inventory is
to bucket risk by boundary:
External JSON. Look for await request.json() as any. Replace it with
unknown input plus a runtime schema.
Generated authority. Look for manually maintained row or API interfaces where the database or OpenAPI spec already owns the shape. Replace them with types regenerated from the authoritative schema.
Internal workflow state. Look for as SomeState after branching or
conditional logic. Replace it with a discriminated union and exhaustive switch.
Legacy library or framework edge. Look for broad @ts-ignore blocks.
Replace them with one local adapter that narrows the value as precisely as the
library allows.
Pick one boundary from the top of the list. A webhook route is a good candidate because the trust boundary is obvious and the repair is small. This is the kind of code that looks productive until the payload drifts:
export async function POST(request: Request) {
const payload = (await request.json()) as any;
await saveProfile({
profileId: payload.id ?? payload.profileId,
stage: payload.stage,
score: payload.score,
});
return Response.json({ ok: true });
}The replacement is not a bigger interface. It is a parser that proves the shape before the rest of the route gets to touch it:
import { z } from 'zod';
const ProfileWebhookSchema = z.strictObject({
type: z.literal('profile.updated'),
data: z.strictObject({
profile: z.strictObject({
profileId: z.string().uuid(),
stage: PipelineStageSchema,
score: z.number().int().min(0).max(5).nullable(),
}),
}),
});
type ProfileWebhook = z.infer<typeof ProfileWebhookSchema>;
export async function POST(request: Request) {
const input: unknown = await request.json();
const payload: ProfileWebhook = ProfileWebhookSchema.parse(input);
await saveProfile(payload.data.profile);
return Response.json({ ok: true });
}That one change gives reviewers a concrete rule to enforce: this route accepts
untrusted input, so bypassing ProfileWebhookSchema is a regression. It also
gives the next agent a local pattern to copy instead of an unsafe cast to repeat.
Generate Contracts Where the Truth Already Exists
Some type contracts should be handwritten because they express product decisions. Others should be generated, because another system already owns the truth.
When PostgreSQL is the source of truth, generate your TypeScript types from
the database schema. Supabase, for example, provides supabase gen types typescript to produce a database.types.ts file from a live or local database.
The specific tool matters less than the habit: stop manually retyping table
shapes that the database already knows.
import type { Database } from '@/lib/database.types';
type ProfileRow = Database['public']['Tables']['profiles']['Row'];
type ProfileInsert = Database['public']['Tables']['profiles']['Insert'];
function toProfileInsert(profile: CanonicalProfile): ProfileInsert {
return {
display_name: profile.displayName,
stage: profile.stage,
score: profile.score,
};
}When an HTTP API defines the contract, generate types from its OpenAPI spec.
openapi-typescript generates
TypeScript types from local or remote OpenAPI 3 documents, giving you typed
request and response shapes through the generated paths and components
types.
import type { paths } from '@/lib/api.types';
type UpsertProfileBody =
paths['/profiles/{profileId}']['put']['requestBody']['content']['application/json'];
type UpsertProfileResponse =
paths['/profiles/{profileId}']['put']['responses'][200]['content']['application/json'];
async function saveProfile(body: UpsertProfileBody): Promise<UpsertProfileResponse> {
const response = await fetch(`/profiles/${body.profileId}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Profile save failed: ${response.status}`);
}
return parseProfileResponse(await response.json());
}Generated types are not magic. They drift if nobody reruns the generator. They get bypassed when someone writes a one-off fetch wrapper instead of using the typed client. They still need runtime validation at the boundary. But they give the codebase a shared, checkable memory of the database or API shape — and CI can verify that memory hasn't gone stale.
Make that last sentence literal. Keep the generated file committed, rerun the generator in CI, and fail if the regenerated file differs from the branch:
{
"scripts": {
"types:db": "supabase gen types typescript --db-url \"$DATABASE_URL\" --schema public > src/lib/database.types.ts",
"types:api": "openapi-typescript openapi.yaml -o src/lib/api.types.ts",
"types:check": "pnpm run types:db && pnpm run types:api && git ls-files --error-unmatch src/lib/database.types.ts src/lib/api.types.ts && git diff --exit-code -- src/lib/database.types.ts src/lib/api.types.ts"
}
}name: Generated type drift
on:
pull_request:
jobs:
generated-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run types:check
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}The exact generator command depends on the stack. The invariant is the same: the schema or spec is authoritative, the generated TypeScript file is a cached copy of that authority, and CI refuses to merge when the cache is stale.
What This Changes for Agents
Types don't make agents safe on their own. They make the agent's work reviewable.
Without typed contracts, the agent satisfies the prompt and hopes the local pattern it copied is still valid. With typed contracts, there is a feedback loop. The agent edits code. TypeScript flags the states, fields, or response shapes it didn't handle. Runtime schemas reject payloads that only looked right. Generated types expose drift between the codebase and the database or API it depends on. Reviewers can ask "why did you bypass the boundary here?" instead of debating style.
This is also why as any and loose casts are so expensive in AI-assisted
codebases. A cast tells the compiler — and the next agent — to stop asking
questions about a value. It turns off the memory exactly where the system needs
it most. The better default is unknown at untrusted boundaries, narrowed
through TypeScript checks or a runtime schema, with the compiler keeping
pressure on everything downstream.
The same logic applies to Record<string, unknown>. It's a fine intermediate
type when you genuinely don't know the shape yet, but it's not a domain model.
If three consumers — a dashboard, a scorer, and an importer — all need the same
fields, name those fields in a shared type. If certain data should not reach the
dashboard, encode that constraint. The dangerous state is leaving the question
open so each consumer answers it differently.
How We Score This in a Diagnostic
In a Post Code Labs diagnostic, we don't score type safety by checking whether the repo "uses TypeScript." A TypeScript project can still be held together by JSON blobs, permissive casts, duplicated interfaces, and comments that no longer match the running system.
We look at specific questions for each critical data path:
- Is the source of truth named and unambiguous?
- Do external inputs start as
unknownand go through a runtime parser? - Are the schemas close to the boundary — the route handler, the importer, the webhook, the job entry point?
- Are database and API types generated from the authoritative schema, not manually maintained?
- Does CI fail on type errors, stale generated types, and unsafe escapes?
- Can a new workflow state be added without silently skipping a consumer?
Each question gets scored as a pass, partial, or gap, with evidence attached. "Pass" means we can point to the schema file, the generation command, the parser, the exhaustive switch, and a CI run that catches drift. "Gap" means the contract is informal or missing — the kind of thing that becomes a production surprise when an agent or a new team member touches the wrong assumption. The goal is never "add stronger types" as an abstract recommendation. It is to show exactly where the memory is missing and what filling it in looks like.
What to Do Next Monday
Pick one critical data path — not the whole app. Choose the path where a wrong assumption would actually hurt: payments, permissions, onboarding, scoring, customer records, or imports.
Then do these six things, each small enough to finish in a sitting:
- Write down the canonical source of truth for that path in one sentence.
- Find every place that reads or writes that data.
- Change the untrusted entry point to accept
unknown. - Add a runtime parser (Zod, Valibot, or whatever your team already uses) for the payload shape that downstream logic actually depends on.
- Model at least one workflow state as a discriminated union with an exhaustive switch in its most important consumer.
- If the database or an API is the authority, add or document the type generation command and wire it into CI so drift fails before merge.
None of this is a rewrite. It's a memory repair — taking a product rule that currently lives in prompts, notes, and tribal knowledge, and moving it into the codebase where humans, agents, editors, and CI can all enforce it.
The payoff is practical, not theoretical: the next person (or agent) who touches that data path has less to rediscover.
References
TypeScript Handbook: Narrowing
[typescriptlang.org]- Zod documentation[zod.dev]
Supabase: Generating TypeScript Types
[supabase.com]- OpenAPI TypeScript documentation[openapi-ts.dev]