# Refactor without changing behavior: null and empty values

> A tidy rewrite often changes what null, zero or an empty string turns into, and tests that call the function normally do not notice. How to catch it.

Published 2026-11-14 · https://aiskills402.com/blog/refactor-without-changing-behavior-empty-values

Refactoring means the program keeps behaving the way it did: whatever went in before produces the same output, or the same failure, afterwards. The promise breaks most quietly around values that are absent or blank. The new code reads better and the usual tests pass, yet a caller handing over `None`, `null`, zero or an empty string gets another answer. Below are the few rewrites that cause it, the language rules that allow it, and a quick comparison to run before a cleanup goes to anyone.

## Why this slips through review

A reviewer reads a diff and asks whether the new code looks equivalent. It usually does. An `or` replaced by a default argument, a `||` replaced by `??`, a guard clause folded into a destructuring pattern: each reads as the same idea in newer clothes. The difference lives in the values nobody wrote a test for, and those are the values that arrive from outside: a field that came back as `null` from a database, a form box left blank, an option forwarded by a wrapper that did not know it was optional.

Tests that exercise the ordinary call keep passing, because the normal call is exactly the case that did not change. The rewrite only differs on the edge, so the edge is where to look.

## The rewrites that change meaning

### A sentinel check turned into a default parameter

Start with Python. Here is a function that treats a missing list and an explicit `None` alike:

```python
def apply_discounts(order, codes=None):
    if codes is None:
        codes = []
    return [c for c in codes if c in order.valid]
```

Someone tidies it into `def apply_discounts(order, codes=())`. Calls that leave `codes` out behave as before. A call that passes `None` on purpose now iterates over `None` and raises a `TypeError`. A parameter default is used when the caller omits the argument, and never overrides a value that was actually passed. Explicit `None` is common in real callers: a wrapper that forwards its own optional argument, a value decoded from JSON, a nullable column.

JavaScript has a close cousin of this rule. A default parameter fills in solely for `undefined`; `null` goes straight through and so does an empty string ([MDN, Default parameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters)). The same holds for defaults inside a destructuring pattern, which are not used when the property holds `null` ([MDN, Destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring)). So this rewrite:

```js
// before
function createLogger(name, options) {
  options = options || {};
  return { name, level: options.level || "info" };
}

// after
function createLogger(name, { level = "info" } = {}) {
  return { name, level };
}
```

looks like a clean modernization, and `createLogger("api", null)` used to work and now throws, because the pattern tries to read `level` from `null`. Preserving the original is easy: read the options through `options ?? {}` first, then destructure. The point is that someone has to notice.

### `or` and `||` against `is None` and `??`

Python's `x or default` does not mean "if x is missing". It means "if x is false". The documentation lists what counts as false: `None`, zero of any numeric type, and every empty string, tuple, list, dict or set ([Python documentation, Built-in Types](https://docs.python.org/3/library/stdtypes.html)). It also says that `or` returns one of its operands rather than a boolean. So `limit = limit or default` replaces a `limit` of zero, while `limit if limit is not None else default` keeps it. Moving from one form to the other, in either direction, changes the answer for zero and for the empty string.

JavaScript has three different sets. The `||` operator falls back for any falsy left side. The nullish coalescing operator `??` falls back only for `null` and `undefined`, which is why `0 ?? 42` stays `0` ([MDN, Nullish coalescing operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing)). Swapping `||` for `??` on the grounds that it is the better operator is a real behavior change for `0`, an empty string, `false` and `NaN`. You may well want that outcome, but then it counts as a deliberate edit and the commit message should say so.

### A dictionary lookup with a fallback

`d.get(key, fallback)` hands back the fallback just for a missing key. If the key exists but holds `None` or an empty string, that stored value comes back. `d.get(key) or fallback` replaces those as well. The two lines look interchangeable in a diff and they are not.

### A truthiness test and its quiet neighbors

`if items:` and `if len(items) == 0` agree for lists and disagree for `None`. A conversion call tagging along, such as `tuple(x)` or `String(x)`, turns `None` into an error or into the text `"None"`. Class field defaults in a dataclass or a typed model behave like parameter defaults: a `None` passed in stays `None`, with no conversion on the way. If the old constructor did the converting, that logic needs a new home rather than vanishing.

## A check that catches these

No tool is required. Before calling a cleanup finished, go through each changed line that picks a result for an absent or blank value: sentinel checks, fallbacks with `or`, `||` or `??`, parameter and destructuring defaults, fallback lookups, truthiness tests, conversion calls and field defaults. For each one, feed these inputs to the original and to the rewrite:

- the argument left out entirely;
- `None` or `null` passed by name;
- the number zero;
- a blank string and a list with no members;
- for a settings object, the entire object given as `null`.

If both versions agree on all of them, you are done. If they differ, you have two honest choices. Either rewrite the line so the original result survives in the modern syntax, or accept the new result and record it: which input, the earlier output, the output now. What you must not do is ship the difference with the word "safe" on it.

It is cheap to script: a table of inputs and a loop calling the original and the rewrite in turn. Put the original in a scratch file for this one purpose; it is the reference the rewrite is measured against.

## Why a model needs telling

We ran a small probe on a model asked to clean up code with nothing more than a bare request. Most of the time it kept the careful cases, but in three of eight runs it replaced a default-on-empty check with a parameter default and described the result as safe, while an explicit `None` or `null` now failed. It is the same trap a person falls into, and for the same reason: the rewrite looks equivalent, and nothing in the request mentions empty values. If you hand cleanup to an assistant, ask it to run those explicit inputs through both versions and to end its answer with what it compared and what differed.

That habit is what our skill [Safe Refactoring](https://aiskills402.com/skills/safe-refactoring) packages for Python, JavaScript, TypeScript and React code. It still modernizes the code you ask about. What it adds is the comparison on empty inputs and a closing note naming the inputs it compared and any result that moved. The store with every skill we write and test is at [aiskills402.com](https://aiskills402.com).

The same logic applies to checks of any kind: a test that stays green on the broken case proves nothing, which is the subject of [a test that can never fail](/blog/test-that-never-fails).

## When this does not apply

The guidance here concerns what absent and blank values turn into. It leaves out edits meant to alter behavior, fixing a defect or adding a capability among them, and says nothing about whether a rewrite runs faster. Code with side effects, such as writing to a database or calling a service, needs a test of its own; running both versions side by side is unsafe there. Other languages share the idea but not the rules, so read that language's reference for its defaults before trusting a rewrite.

## What we did not measure

The probe behind the paragraph above was small: eight runs of one model on a few functions we wrote ourselves, without tools, read by hand. It shows a pattern, not a rate you can plan around. We did not test other models for this guide, other languages, large codebases or code with heavy framework magic. The list of rewrites here is common, not complete; another language, or a custom decorator, can add its own way to change what an empty value means. And comparing the two versions only finds the differences among the inputs you thought to feed them, so an input shape nobody listed can still slip by.
