Blog/"I Understand It" Is Weaker Than "I Can Change It"

"I Understand It" Is Weaker Than "I Can Change It"

A learner who can change code without breaking its behavior understands more than a learner who only recognizes the syntax. Recognition is useful. It is also cheap. The stronger proof is this: can you make a deliberate change, preserve the contract, and explain what changed?

That is a different kind of fluency.

This is narrower than saying practice should test decisions or that learners should predict output before running code. Those are both good habits. The change test comes after that. It asks whether the learner can take code that already exists, move it on purpose, and keep the important promises intact.

Plenty of learners can look at a function and say, "That is an arrow function." They can look at map() and say, "That transforms an array." They can look at addEventListener() and say, "That handles a click." Those statements are not wrong.

They are just not enough.

The real test begins when the requirement moves.

Recognition Feels Better Than It Measures

Recognition gives fast confidence. A learner sees familiar code and feels oriented:

const activeNames = users
  .filter((user) => user.active)
  .map((user) => user.name);

They know the words. filter(). map(). Callback. Return.

Good. That is a start.

But recognition can hide a shallow model. If the same learner cannot explain why filter() must happen before map() here, or cannot change the code to keep only verified users without mutating the original array, the model is still fragile.

The code is familiar in the same way a sentence in a foreign language can be familiar. You can identify some words. That does not mean you can rewrite the sentence without changing its meaning.

Programming skill lives in that rewrite.

The Better Test Is A Constrained Change

"Do you understand this?" is too vague to be useful.

Ask for a constrained change instead:

Change this code so it also requires a verified email.
Keep the output shape the same.
Do not mutate the input array.
Explain which part of the behavior changed.

Now the learner has to reason about the contract:

const activeVerifiedNames = users
  .filter((user) => user.active && user.emailVerified)
  .map((user) => user.name);

The output is still an array of names. The source array is still unchanged. The selection rule became stricter. The transformation step stayed the same.

That explanation matters. It proves the learner is not just moving symbols around until the test turns green.

The same pattern works across beginner JavaScript:

TopicRecognition saysChange proves
Functions"This returns a value.""I can add an argument without changing old calls."
Arrays"This uses filter().""I can keep a smaller view without mutating the source."
DOM"This selects an element.""I can target the right copy when the page has repeated markup."
Async"This uses await.""I can change the timing model while preserving the resolved value."

That last column is where understanding gets harder to fake.

Functions: The Return Value Has To Survive The Edit

Function exercises are a clean place to see the difference.

This code looks close:

const formatDollars = (cents) => {
  "$" + (cents / 100).toFixed(2);
};

A learner may recognize the arrow function, the braces, the division, and toFixed(). They may even know what the output should be.

But callers receive undefined.

The small fix is an explicit return:

const formatDollars = (cents) => {
  return "$" + (cents / 100).toFixed(2);
};

That repair is useful. A stronger follow-up is:

Now support a currency symbol argument.
Existing calls should still return dollar strings.
const formatMoney = (cents, symbol = "$") => {
  return symbol + (cents / 100).toFixed(2);
};

That change tests more than syntax. The learner has to preserve the old behavior with a default parameter, keep the return type as a string, and explain that only callers who pass the second argument see different output.

That is why a standalone exercise like Arrow Function Returns matters. The first win is fixing the missing return. The deeper win is learning to track what the caller receives after the function body changes.

Arrays: A Working Result Can Still Break The Contract

Array code is where recognition gets especially sneaky.

Here is a common beginner repair:

function openHighPriorityTickets(tickets) {
  tickets.forEach((ticket, index) => {
    if (ticket.status !== "open" || ticket.priority < 3) {
      tickets.splice(index, 1);
    }
  });

  return tickets;
}

The learner may recognize forEach(), conditionals, splice(), and return. It may even appear to work with a simple data set.

Then adjacent non-matching items expose the problem:

const tickets = [
  { id: 1, status: "closed", priority: 5 },
  { id: 2, status: "closed", priority: 4 },
  { id: 3, status: "open", priority: 3 },
];

const result = openHighPriorityTickets(tickets);

console.log(result.map((ticket) => ticket.id)); // [2, 3]
console.log(tickets.length); // 2

Two things went wrong. The loop skipped a shifted item, and the source queue was changed. The code did not merely choose the wrong method. It broke the contract.

The behavior-preserving version returns a filtered view:

function openHighPriorityTickets(tickets) {
  return tickets.filter((ticket) => {
    return ticket.status === "open" && ticket.priority >= 3;
  });
}

Now the learner can say:

The function returns a new array.
It keeps only open tickets with priority 3 or higher.
It does not remove anything from the input array.

That is real understanding. Not because filter() is fancy. Because the learner can name the relationship between the old data and the new data.

This is the pressure behind exercises like Filter Without Removing and Map Callback Returns. They are not vocabulary drills. They ask learners to preserve shape, return values, and source data while changing the implementation.

DOM: The Page Gets Bigger And The Old Selector Lies

DOM understanding often looks good until the HTML grows.

This code works when there is one widget:

const status = document.querySelector(".status");
const widget = document.querySelector(".widget");

status.textContent = "Connected";
widget.classList.add("is-connected");

It fails when the page has two widgets using the same classes. querySelector(".status") still does exactly what it promises: it returns the first match. The learner's assumption was the weak part.

The safer change is to select the specific widget first, then query inside it:

const widget = document.querySelector('[data-widget="support"]');
const status = widget.querySelector(".status");

status.textContent = "Connected";
widget.classList.add("is-connected");

The syntax is not the main lesson. The contract is.

Only the support widget changes.
The billing widget stays unchanged.
Both widgets remain in the DOM.

That is why Debug a Selector is stronger than a task that only says, "Use querySelector." A learner who can scope the selector when the markup repeats has a better model than one who can merely recite that querySelector returns the first match.

The same idea shows up in DOM projects. In the Habit Tracker project, adding a new habit should not break toggling existing habits. Rendering progress should not make the list the source of truth. Each stage changes one behavior while earlier behaviors remain accountable.

That is exactly the muscle learners need outside a practice environment.

Async: Refactoring Timing Without Changing The Result

Async code makes the distinction even sharper because the code can look right while the timing is wrong.

This version waits for three independent operations one after another:

async function run() {
  const name = await fetchName();
  const score = await fetchScore();
  const level = await fetchLevel();

  console.log(name + " " + score + " " + level);
}

A learner can recognize async, await, and the final log. But can they change the control flow without changing the result?

async function run() {
  const [name, score, level] = await Promise.all([
    fetchName(),
    fetchScore(),
    fetchLevel(),
  ]);

  console.log(name + " " + score + " " + level);
}

The output stays the same. The scheduling changes. The requests can now be in flight together.

That is a more demanding explanation than "Promise.all runs promises." The learner has to know which operations are independent, what order the result array uses, and why this would be wrong if fetchScore() needed the name first.

The Async Patterns & Pitfalls lesson uses this kind of refactor pressure deliberately. The task is not just to recognize async syntax. It is to preserve external behavior while changing the async control flow underneath.

That is the kind of change real code asks for all the time.

Checkpoints Should Mix The Skills On Purpose

Single-concept practice is necessary. It gives learners a clean signal.

But a learner also needs checkpoints where the labels come off.

The Loops Checkpoint does this by mixing console output, variables, data types, operators, strings, conditionals, loops, prediction, and debugging. The DOM checkpoint does the same with selectors, text updates, events, forms, traversal, localStorage, and timers.

For this argument, the important part is not only that checkpoints combine skills. It is that they ask learners to keep earlier facts true while the task changes. A prediction still has to match. A fixed bug still has to respect the original rule. A DOM update still has to leave unrelated elements alone.

That structure matters because real programming rarely announces the concept being tested.

When a task says "fix the off-by-one loop," the learner has a hint before they start. When a checkpoint asks them to read a small program, predict output, repair a bug, and pass tests, they have to choose the tool themselves.

That is transfer.

Course checkpoints should not feel like trivia rounds. They should feel like small rehearsals for maintenance:

Read what exists.
Predict what should happen.
Change the smallest useful thing.
Verify the behavior.
Explain the effect.

That loop is closer to real development than memorizing another definition.

Guided Projects Make The Change Cost Visible

Projects add a different kind of pressure: previous work has to keep working.

In a standalone exercise, the whole problem can fit in your head. In a guided project, stage 8 depends on choices from stages 2 through 7. The learner cannot treat each task like an isolated trick. They have to keep the app coherent.

That is the point.

In Build Connect Four, modeling the board is not separate from rendering discs, handling clicks, blocking full columns, detecting wins, and resetting the game. A change to makeMove() has consequences for turn switching, win detection, and whether later moves should be ignored after the game is over.

A learner who truly understands the board model can change one part and predict which tests should still pass.

In the Habit Tracker project, the same pattern is gentler but just as important:

Data changes.
The list renders from data.
Events update data.
The summary renders from the same data.

Add form validation, and habit toggling should still work. Add progress text, and creating a habit should still update the list. Add a new helper, and the rendered page should still reflect the source array.

That is not just building. That is practicing controlled change.

A Better Question Than "Do You Understand?"

Teachers, mentors, and learners can all ask better questions.

Instead of:

Do you understand functions?

Ask:

Can you change this function so old calls still work?
Can you explain what the caller receives before and after the change?

Instead of:

Do you know array methods?

Ask:

Can you replace this mutation with a returned copy?
Can you prove the original array is still intact?

Instead of:

Do you know DOM selectors?

Ask:

Can you make the same code safe when the component appears twice?
Can you explain which element changes and which one does not?

Instead of:

Do you know async/await?

Ask:

Can you make independent work concurrent without changing the resolved value?
Can you explain which dependency would make that refactor unsafe?

These questions are harder. They are also fairer. They measure the thing we actually want learners to build.

The Real Signal

Recognition is the beginning of understanding, not the finish line.

A learner should recognize syntax. They should know the names of tools. They should be able to read a plain example without flinching every time JavaScript uses braces in a new emotional configuration.

But skill shows up when the code has to move.

Can the learner change a function and preserve its return value? Can they refactor array code without mutating data other code still needs? Can they scope a DOM update when the page gains another matching element? Can they change async timing without changing the result?

That is the signal.

"I understand it" is easy to say after reading the explanation.

"I can change it, preserve what matters, and explain the effect" is a much better claim.

Key Takeaways

  • Recognition is useful, but it is a weak measure of programming skill by itself.
  • Behavior-preserving changes force learners to track contracts, output shape, mutation, side effects, and timing.
  • Debugging, transfer, and refactoring exercises reveal mental models better than syntax identification.
  • Guided projects make earlier decisions accountable because later stages depend on them.
  • Good checkpoints remove the concept labels and ask learners to choose, change, verify, and explain.
Share this post: