Definition
A false type contract is a type that compiles fine but promises something the implementation doesn't actually guarantee.
Code with type errors gets fixed fast. The problem is the opposite case: the types are quiet, but the runtime doesn't keep the promise those types made. A type like that isn't a safety net, it's false confidence. It makes whoever reads it think "this one's already been checked" and skip the check.
The principle is one line. A type should promise only as much as the implementation actually guarantees.
Why it matters
Let's look at a concrete accident.
function groupByStatus(orders: Order[]): Record<OrderStatus, Order[]> {
const result = {} as Record<OrderStatus, Order[]>;
for (const o of orders) {
(result[o.status] ??= []).push(o);
}
return result;
}
// consumption site
const grouped = groupByStatus(orders);
grouped.refunded.length; // 💥 undefined when there are no refunded orders
The type says Record<OrderStatus, Order[]>. That means "every OrderStatus key exists in the result." But the implementation only fills in the statuses it observed. The type spoke more strongly than the runtime.
The consumption site did nothing wrong. The type said it was there, so it used it. The responsibility sits with whoever wrote the false contract.
What makes these contracts dangerous is that the failure blows up not at the definition site but far away at the consumption site. And that consumption site is blameless.
How it works
False contracts show up in a few recurring shapes.
1. Record<K, V> is a totality contract
Record<K, V> promises "every K exists." Use it only when the implementation keeps that promise.
| What the implementation does | Key domain | Correct type |
|---|---|---|
| Initializes every key upfront | finite union | Record<K, V> |
| Fills only observed keys (groupBy, etc.) | finite union | Partial<Record<K, V>> |
| Fills only observed keys | open domain like IDs | Map<K, V> |
Putting Partial<Record<K, V>> over an open key domain is just hand-rebuilding the V | undefined lookup contract that Map already gives you.
This is a different problem from the rule about not using Partial<DomainEntity> as a mutation payload. The latter is a patch that loses its operational meaning; the former is a result representation saying only some keys exist at runtime. Lumping them under one rule and banning both is a misapplication.
2. A type predicate carries an obligation to check
value is T promises "if this function lets it through, it really is a T."
// ❌ checks nothing, yet promises a domain type
function isUser(v: unknown): v is User {
return typeof v === "object" && v !== null;
}
// ❌ a predicate that just wraps an `as`
function isUser(v: unknown): v is User {
return Boolean(v as User);
}
// ✅ only simple, accurate narrowing
function isNotNil<T>(v: T | null | undefined): v is T {
return v != null;
}
Complex domain types arriving at a boundary belong to a schema parser, not a predicate. Leave predicates for definite judgments at the level of isNotNil.
3. A wrapper's return contract follows its execution timing
Preserving the call contract (Parameters) is fine, but preserving ReturnType is only valid when the wrapper actually returns a value on that same call.
type AnyFn = (...args: never[]) => unknown;
// debounce / schedule — no value on this call
type Deferred<F extends AnyFn> = (...args: Parameters<F>) => void;
// cache — no value on a miss
type Cached<F extends AnyFn> = (...args: Parameters<F>) => ReturnType<F> | undefined;
// async wrapper
type Wrapped<F extends AnyFn> = (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>;
If you declare that debounce(fn) returns ReturnType<typeof fn>, the call site believes a value arrives immediately. What actually arrives is undefined.
4. Excess property checks are not sanitizers
This one gets misunderstood constantly.
type PublicUser = { id: string; name: string };
const source = { id: "1", name: "foo", passwordHash: "..." };
const user: PublicUser = source; // ✅ compiles
JSON.stringify(user); // 💥 passwordHash goes out as-is
Excess property checks only fire when you assign an object literal directly. Assign a variable and they don't fire at all — and even when they do, they never strip fields at runtime. Types disappear after compilation.
Removing sensitive fields is owned by a runtime projection or a parser.
// ✅ this actually removes them
const user: PublicUser = { id: source.id, name: source.name };
5. Key-remapped return types must be isomorphic to the runtime
If the function doesn't actually transform the keys, slapping on a return type like ToCamelCaseKeys<T> is a false contract. The type shows userName while the runtime object holds user_name.
Applying it in practice
The self-check question
Ask this before reaching for a new type.
Does the implementation keep this type's promise on every path?
If even one path doesn't, weaken the type. A weak type plus an explicit check always beats a strong type plus a weak implementation.
// weak type — forces the consumption site to check
function groupByStatus(orders: Order[]): Partial<Record<OrderStatus, Order[]>>;
const grouped = groupByStatus(orders);
const refunded = grouped.refunded ?? []; // the check is now mandatory
Don't report satisfies / as const as validation
satisfies, as const, and type annotations are all compile-time tools. They don't validate or sanitize runtime data.
// ❌ this validated nothing — you just talked the compiler into it
const config = JSON.parse(raw) as AppConfig;
// ✅ this actually checks
const config = appConfigSchema.parse(JSON.parse(raw));
How to catch it in review
The fastest way to find this family in code review is to put the type and the implementation side by side and look for one counterexample. "If this function gets an empty array, does the return value satisfy that type?" — one sentence usually exposes it.
Trade-offs
Weak types push work onto the consumption site. Switch to Partial<Record<K, V>> and every call site grows a ?? [] or an optional chain. The code looks messier, and someone says "it didn't used to be like this."
But that mess is accurate. All that happened is that the possibility of absence became visible in the code. The version that looked clean was hiding the fact that a value might not be there.
There's a cost in the other direction too. Fixing the implementation to honor the strong contract is always an option. Pre-initialize the groupBy result with every key and Record<K, V> becomes true. When the keys are finite and few, that's often the better side. Weigh which is cheaper each time — weakening the type or strengthening the implementation.
When not to use this
There are places where over-applying this document's rules goes wrong.
- Turning every
Recordinto aPartial. If the implementation really does fill every key,Recordis the accurate type. Weakening it unnecessarily just adds meaningless null checks at the consumption site. - Banning all predicates. For simple, accurate narrowing like
isNotNil, a predicate reads best. - Never using
as const.as constis a literal-preservation tool, not a validation tool. As long as you don't report it as validation, use it freely.
Common mistakes
- Starting from
{} as Record<K, V>and filling only part of it. The flagship case in this document. The assertion you started with is what makes the contract false. - Turning API responses into domain types with
as. That's not parsing, it's silencing the compiler. When the server changes its shape, it blows up deep inside the app. - Believing excess property checks erase fields. Types don't exist at runtime.
- Using
Partial<DomainEntity>as a mutation payload. There's no way to express whetherundefinedmeans "keep" or "delete." Split it into an operation union likerename/clear-description. - Treating the
catchvalue likeany. WithuseUnknownInCatchVariablesoff, caught errors flow around unchecked. - Erasing errors with
@ts-ignoreor a double assertion. The error goes away; the false contract stays.
Related concepts
- state-modeling-ladder — the order for deciding whether you actually need a state union
- type-level-testing — leaving compiler evidence of what a contract really blocks
- typescript-variance — the spots TypeScript deliberately left unsound
- typescript-environment-contract — a few of these rules are only enforced with the right compiler flags on