jason duffett.

Tidy your tests with ts-fake 1.0.0

tech 6 min read

I spent a lot of 2024 and 2025 working on TypeScript monorepo projects. One thing that really bugged me: changes to database schema, API model, or DTO interfaces that would cascade type-check failures across dozens of unrelated tests. The tests didn’t actually depend on the new field — they just held a fully-populated fixture, and the literal stopped compiling.

We tried a few approaches across these teams; none felt satisfying. So I wrote a ten-line utility called ts-fake, and after a year of using it across projects and - more recently watching coding agents pick it up cleanly - it’s now at 1.0.0.

This post is a walk through the problem, the implementation (it really is ten lines), and how the library behaves when interfaces evolve.

The test I want to write

Suppose I’m testing this function:

function processUser(user: UserProfile): string {
  return `Processing ${user.username} from ${user.organization.name}`;
}

UserProfile is the usual chunky DTO:

interface UserProfile {
  userId: string;
  username: string;
  email: string;
  roles: Role[];
  organization: Organization;
  metadata: Record<string, string>;
}

The function reads two fields. So the test should only need to mention two fields:

expect(processUser(/* ??? */)).toBe("Processing testuser from Test Co");

The question is what goes in the ???. Every option I’d reach for has a problem.

Four wrong answers

1. Build the whole object.

const user: UserProfile = {
  userId: "u-1",
  username: "testuser",
  email: "test@example.com",
  roles: [],
  organization: {
    id: "o-1",
    name: "Test Co",
    settings: {},
  },
  metadata: {},
};

Verbose, brittle, and the test now lies about what it depends on. Add a non-optional field to UserProfile and every literal like this one breaks — including this test, which doesn’t care about the new field.

2. Share a fixture.

import { aUser } from "./fixtures";
const user = { ...aUser, username: "testuser" };

Now this test and every other one importing aUser share state. Tweaking aUser to suit one test risks breaking others, so people stop tweaking it. They adapt their assertions to whatever data aUser already has, rather than writing the data their test actually needs. The fixture becomes a constraint and the variation that was the whole point of having multiple tests quietly disappears. Less typing that option 1, but a worse outcome.

3. Double-assert through unknown.

const user = {
  username: "testuser",
  organization: { name: "Test Co" },
} as unknown as UserProfile;

Compiles. Runs. Also compiles when you write usrname: "testuser". Also compiles when you forget organization entirely and the function throws at runtime. The cast bypasses the type system — which is the only reason you reached for TypeScript in the first place.

4. Use Partial<UserProfile>.

const user: Partial<UserProfile> = {
  username: "testuser",
  organization: { name: "Test Co" } as Organization,
};
processUser(user); // ❌ Partial<UserProfile> is not assignable to UserProfile

Closer — at least the input is type-checked. But the function expects a UserProfile, not a Partial<UserProfile>, so you have to cast back, and now you’re at option 3 with extra steps.

The fix

import { fake } from "ts-fake";

const user = fake<UserProfile>({
  username: "testuser",
  organization: { name: "Test Co" },
});

expect(processUser(user)).toBe("Processing testuser from Test Co");

The input is checked: usrname errors, organization: { naem: "x" } errors, passing a number for username errors. The output is typed as UserProfile, so it slots straight into processUser with no cast.

The whole library

Here’s the source. The whole thing:

type DeepPartial<T> = T extends (...args: unknown[]) => unknown
  ? T
  : { [P in keyof T]?: DeepPartial<T[P]> };

export function fake<T>(partial?: DeepPartial<T>): T {
  if (partial === undefined) return {} as T;
  return partial as T;
}

That’s it. No proxies, no defaults, no runtime magic. The value fake<T>(x) returns is literally x. The work is all at the type level:

That lie is the whole point. At runtime fake<T>(x) is just x — unset fields are genuinely undefined, regardless of what TypeScript says. That’s the contract, not a hazard: the fake declares what the test depends on, and a test that reads past its declaration fails loudly. The failure tells you the test was depending on something the fixture never claimed, which is exactly what you want to know.

What happens when the interface changes

Here are three day-in-the-life scenarios demonstrating the maintainability claim.

Day 1 — you write the test. As above. Two fields, three lines, passes.

Day 30 — someone adds a field. A non-optional shippingAddress: Address is added to UserProfile. Each option behaves differently:

Day 60 — a typo. Someone writes fake<UserProfile>({ usrname: "testuser" }). TypeScript flags it as an unknown property. With as unknown as UserProfile the same typo compiles, and you find out at runtime — if at all.

Nesting

The examples in the repo use fake<> recursively:

const customer = fake<Customer>({
  name: "Jane",
  address: fake<Address>({ city: "NYC" }),
});

DeepPartial already recurses, so you don’t strictly need the inner fake<Address>address: { city: "NYC" } works fine. The reason to write the inner fake<> is reuse:

const nycAddress = fake<Address>({ city: "NYC" });
const customer = fake<Customer>({ name: "Jane", address: nycAddress });
const business = fake<Customer>({ name: "Acme", address: nycAddress });

For one-off tests, inline the partial. For shared scaffolding, extract a fake<> and reuse it.

When not to use it

A note on coding agents

fake<T> is unusually friendly to coding agents because the type signature is the spec, and the spec is enforced at the call site. An agent can’t hallucinate naem, can’t cast around the type system, and can’t drift from the interface as it evolves. This is why I’ve kept reaching for it on agent-driven projects: the boundary between “what the test cares about” and “what the type system enforces” stays sharp without anyone having to police it.

Try it

Ten lines on npm and on GitHub. Read the source — it’s quicker than reading this post. Break it and tell me what’s missing.