DEV Community

Dakota Huang
Dakota Huang

Posted on

When Types Can't Save You: Behavior Contracts for Messy Functions

Messy code hides its contract. Comments lie. Names mislead. A type system only covers a fraction of the surface, and usually none of the side effects. When nobody on the team understands what a function does, the only reliable source of truth is observed behavior.

This article walks through a five-step contract workflow. It is built for the bottom drawer of the codebase. You start with observations, not with the implementation. Then you make the smallest change that keeps the recorded behavior green.

Step 1: Pick the function with the highest fan-in

Fan-in is the number of places that call a function. High fan-in means high blast radius. Start there, not at the ugliest function you can find. A silent regression in a widely used function will burn a week. A cosmetic cleanup in a private helper changes nothing.

Limit yourself to one function and one session. A contract for a single function takes about thirty minutes. A contract for a module takes a day and usually collapses under its own weight.

Step 2: Observe before you read

You will be tempted to open the implementation. Resist. Instead, collect four categories of observed behavior:

  1. Return values
  2. Exceptions thrown
  3. Object mutations
  4. Side effects

Suppose a legacy checkout module has processOrder(order, db). Before reading its code, you watch real calls for a day. Your observation log looks like this:

Observation Category
Returns { ok: true, total } for a normal order Return value
Throws Error("Bad order total") when total is not finite Exception
Always calls db.save with a rounded total Side effect
Defaults missing price to 0 and missing quantity to 1 Return value

Write that table in a plain Markdown file. This file is your contract draft. It answers one question: what behavior can callers rely on today?

Step 3: Turn each observation into a runnable test

Now you read the implementation, but only to confirm call signatures and field names. The tests below encode the observation table exactly.

import assert from "node:assert/strict";
import { test } from "node:test";
import { processOrder } from "./legacy.js";

test("returns ok with total for a normal order", () => {
  const db = { save() {} };
  const order = { id: "1", items: [{ price: 10, quantity: 2 }] };
  assert.deepEqual(processOrder(order, db), { ok: true, total: 20 });
});

test("throws when the total is not finite", () => {
  const db = { save() {} };
  const order = { id: "2", items: [{ price: Infinity }] };
  assert.throws(() => processOrder(order, db), /Bad order total/);
});

test("defaults missing price and quantity to 0 and 1", () => {
  const db = { save() {} };
  const order = { id: "3", items: [{}] };
  assert.equal(processOrder(order, db).total, 0);
});

test("persists a rounded total", () => {
  let saved;
  const db = { save: (row) => { saved = row; } };
  const order = { id: "4", items: [{ price: 10, quantity: 3 }] };
  processOrder(order, db);
  assert.equal(saved.total, 30);
});
Enter fullscreen mode Exit fullscreen mode

Node's built-in test runner is enough. No framework, no heavy mocks. Each test points at exactly one observation. If one breaks, you know which behavior changed.

Step 4: Freeze the contract

Run the suite. It does one of two things.

  • It passes: the observations match the implementation.
  • It fails: the test is wrong, not the code. Correct the test, re-run, and commit.

That commit is your baseline. From here on, a green suite means the contract still holds. A red suite means visible behavior changed. Without this baseline, every later diff is guesswork.

Step 5: Make the smallest safe change

Pick one branch. Change it. Re-run the suite.

  • Green: the refactor preserved the contract. Accept the change.
  • Red: you changed behavior. Revert, or write a new failing test first if the change was intentional.

Rename an internal variable. Extract a helper. Move two independent statements. Each of those should keep the suite green. If it goes red, you did not refactor; you changed behavior, and that deserves its own commit.

The decision table

Use this to decide whether an edit is a refactor or a behavior change:

Edit Kind New test needed?
Extract a pure helper from duplicated math Refactor No
Fix a discount calculation bug Behavior change Yes, add a failing test first
Add logging before db.save Behavior change Yes, assert the log output
Rename a parameter Refactor No
Add an early return for null input Behavior change Yes

The pattern is simple. If the public surface changes, it is a behavior change. If only the inside changes, it is a refactor. Run the suite after every edit, even the ones marked safe.

Who should not use this workflow

Teams with full type coverage and a tight contract have better tools. If you understand the module completely, write intention-revealing tests instead of lock-in tests.

Skip this workflow for pure functions. A function with no side effects and no observable state deserves property-based tests, not behavior snapshots. Use the contract where memory is empty: abandoned files, vendored code, emergent APIs.

Where assistant tools actually fit

You do not need any external service for this workflow. Node, one test file, and a terminal are enough.

MonkeyCode's free model access and free server option are the two product claims relevant here. The model access drafts the first version of a test skeleton. The server option runs that skeleton outside your laptop. Neither replaces the observation table you wrote in step two.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

A model does not know which behaviors are observable in your system. You do. That is why the human writes the contract and the machine only assembles the skeleton.

Start with one function. Write five tests. Run them. Make one small change. Run them again.

Try it on the file you are most afraid to touch. The contract will do the arguing for you.

Top comments (0)