Skip to main content
CleanCodeMastery

Replace Array with Object: Give Every Slot a Name

Replace Array with Object explained simply — why an array with secret positions like row[0], row[1], row[2] causes bugs, and how a class with named fields makes the code honest and safe.

24 min read Updated June 11, 2026beginner
refactoringreplace array with objectorganizing dataprimitive obsessionnamed fieldstypescriptcsharp

🧊 The Mystery List on the Fridge

In the Sharma house in Pune, there is a small whiteboard on the fridge. One Monday morning, Ravi's mother Sunita finds this written on it:

Ravi, 7, blue

She stops and stares. What is 7? Is it Ravi's class? His roll number? The number of rotis to pack for tiffin? And blue — is that his house colour for sports day, the colour of the uniform to iron, or the water bottle he lost last week?

She calls Ravi's father, Deepak, who is already on his scooter. He shouts over the traffic, "Oh, I wrote that! First is the name, second is the class, third is the sports house. The school called — they need it for the annual day form." Fine — but that rule lives only in Deepak's head. The whiteboard itself says nothing.

Sunita fills the form correctly because she asked. But on Wednesday, Dadi — Ravi's grandmother — reads the same board while packing tiffin. She does not know Deepak's secret rule. To her, "Ravi, 7, blue" obviously means: Ravi wants 7 rotis, in the blue tiffin box. So Ravi opens his bag at lunch and finds seven rotis stacked like a small tower. His friends laugh for a week. They start calling him "Seven Roti Sharma".

Nobody in this story is careless. Dadi read the board carefully. Deepak wrote real information. The data was correct the whole time. What failed was the format: the meaning of each item was stored in one person's memory instead of on the board itself.

Now imagine instead a proper label card pinned on the fridge:

Name:   Ravi
Class:  7
House:  Blue

Same information. Zero confusion. Anyone — Sunita, Dadi, a visiting aunt — reads it correctly on the first try, because every value carries its own name. There is no secret rule to know, so there is no secret rule to forget.

Our programs face the same two choices every day. We can store mixed information in an array, where position 0 secretly means "name" and position 1 secretly means "class". Or we can store it in an object, where every value sits behind a proper label. The refactoring that moves us from the cryptic whiteboard to the label card is called Replace Array with Object.

Figure 1: The fridge list journey — the same data goes from confusion to clarity

🤔 What is Replace Array with Object?

Replace Array with Object is a refactoring for arrays that are not really lists. It applies when an array is being used like a form — slot 0 holds one kind of information, slot 1 holds a different kind, slot 2 yet another. We replace such an array with an object (usually a small class) that has one named field for each slot.

Think about what an array is supposed to say. An array announces: "I am a sequence of similar things." A list of marks. A queue of customers. A row of temperatures. Every element is the same kind of thing, and the position only tells you which one, not what it is. The fifth mark and the ninth mark are both marks; their position is just their address.

But sometimes programmers misuse an array as a cheap record:

  • row[0] is the team name
  • row[1] is the number of wins
  • row[2] is the number of losses

Here the position carries meaning. The array is lying about its own nature — it looks like a list of similar items, but it is secretly a structure with three different fields. Martin Fowler catalogued this refactoring in his book Refactoring, and Refactoring Guru describes the problem with a lovely image: it is like using post office boxes, keeping the username in box 1 and the address in box 14. The day someone puts a value in the wrong box, things fail in confusing ways.

Figure 2: Two roads — the secret-position array forces guessing; the named object removes guessing entirely
💡

A quick test you can apply in two seconds: ask, "If I shuffled this array, would it still make sense?" A list of marks survives shuffling — it is a true list. But shuffle ["Ravi", "7", "blue"] and you get ["blue", "Ravi", "7"] — pure nonsense. If shuffling destroys meaning, the array is really a record in disguise, and it wants to become an object.

After the refactoring, the data is self-describing. team.wins cannot be mistaken for team.losses. The compiler knows wins is a number and name is a string. And the new object becomes a natural home for behaviour, like a winRate() method.

College corner: in type-theory terms, this refactoring moves you from a positional encoding to a nominal one. An array of strings says only "n strings live here" — its type carries zero information about roles. A class declares each role as part of the type itself, so the compiler can prove facts like "wins is always a number" at every single usage site. There is a halfway house: a tuple type such as [string, number, number] in TypeScript gives per-position type checking but still no names. Tuples are structural typing with positions; classes and interfaces give you named structure. The rule of thumb taught in most software engineering courses applies here: the further a piece of data travels from where it was created, the more its meaning must travel with it — and names are how meaning travels.

🚨 When do we need it?

Watch for these signs in your codebase:

  1. Comments explaining positions. If you see // row[1] = wins, row[2] = losses, the code is confessing its own crime. Honest code does not need a decoder ring.
  2. Everything forced into one type. An array in most typed languages holds one element type. So the class number 7 gets stored as the string "7", and every reader must remember to convert it back with Number(...). Forgotten conversions create silent bugs — "7" + 1 is "71" in JavaScript, not 8.
  3. Index bugs. Someone reads row[1] when they meant row[2]. Someone inserts a new field in the middle and every index after it shifts. The compiler cannot catch any of this, because to the compiler, all the slots look the same.
  4. The Primitive Obsession smell. This refactoring is one of the standard cures for Primitive Obsession — the habit of using raw built-in types (here, a raw array) for a rich domain concept that deserves its own shape.
  5. Data that travels together. If the same three values are always created together, passed together, and read together, they are one concept. Concepts deserve names.

And one clear sign that you do not need it: the array holds many of the same thing. A list of fifty marks should stay an array. Replace Array with Object is only for the impostor arrays.

The quadrant below is a useful mental map when you are deciding. Two questions: do the slots mean different things, and how far does the data travel?

Figure 3: Deciding whether an array deserves to become an object

Read it like this: the fridge list sits in the danger corner — mixed meanings, used by the whole family — so it must become a labelled card. A list of marks travels far too, but every slot means the same thing, so it stays a healthy array. A mixed pair used inside one short function can survive as a tuple. Refactor where meaning is mixed and the data travels.

👀 Before and after at a glance

Here is the fridge-whiteboard problem, written as code. Before — a school stores each student record as a plain array of strings:

// BEFORE: position is a secret rule
// record[0] = name, record[1] = class, record[2] = sports house
const record: string[] = ["Ravi", "7", "Blue"];
 
function makeIdCard(record: string[]): string {
  // Hope everyone remembers the secret order...
  return `${record[0]} | Class ${record[1]} | ${record[2]} House`;
}
 
function isSenior(record: string[]): boolean {
  return Number(record[1]) >= 6;   // must remember: [1] is class, and it's a string!
}

After — the same data as an object with named, properly typed fields:

// AFTER: every value carries its own label
class StudentRecord {
  constructor(
    public readonly name: string,
    public readonly studentClass: number,
    public readonly house: string,
  ) {}
 
  isSenior(): boolean {
    return this.studentClass >= 6;
  }
 
  idCardLine(): string {
    return `${this.name} | Class ${this.studentClass} | ${this.house} House`;
  }
}
 
const ravi = new StudentRecord("Ravi", 7, "Blue");
ravi.isSenior();      // true — no Number() conversion, no index guessing
ravi.idCardLine();    // "Ravi | Class 7 | Blue House"

Notice three wins. The class is now a real number, not a string wearing a number costume. The senior check moved inside the object, next to the data it uses. And nobody anywhere needs to remember what position 1 means, because position 1 no longer exists.

Figure 4: The shape of the result — a small class where every slot became a named, typed member

Compare the two storage styles side by side, the way Dadi would:

QuestionImpostor arrayNamed object
What does slot 1 mean?Only Deepak knowsThe field is literally called studentClass
What type is the class number?A string pretending to be a numberA real number, checked by the compiler
Can the compiler catch a mix-up?No — all slots look identicalYes — name and wins are different fields and types
Where do calculations live?Scattered in outside functionsAs methods next to the data they use
What happens when a field is added?Every index after it silently shiftsOld fields keep their names; nothing shifts

🪜 Step-by-step, the safe way

Refactoring is like crossing a busy road — never run, move one safe step at a time, and check both sides (run the tests) after each step. Here is the safe route, based on the mechanics Fowler describes.

Step 1: Create the new class, empty at first.

class StudentRecord {
  // fields will arrive one by one
}

Step 2: Pick the easiest slot first and add a named field for it. Usually that is slot 0. Add the field along with a way to build the object from the old array, so old and new can live side by side for a while:

class StudentRecord {
  constructor(public readonly name: string) {}
 
  static fromArray(record: string[]): StudentRecord {
    return new StudentRecord(record[0]);
  }
}

Step 3: Replace reads of that one slot, caller by caller. Everywhere the code says record[0], change it to use student.name. Compile and run the tests after each change. The program still works at every moment — that is the whole point of moving in baby steps.

Step 4: Repeat for every remaining slot. Add studentClass (converting "7" to the number 7 inside fromArray, so the conversion happens in exactly one place), then house. Update callers one at a time.

class StudentRecord {
  constructor(
    public readonly name: string,
    public readonly studentClass: number,
    public readonly house: string,
  ) {}
 
  static fromArray(record: string[]): StudentRecord {
    return new StudentRecord(record[0], Number(record[1]), record[2]);
  }
}

Step 5: Delete the array form. When no code reads any index any more, change the creation sites to build StudentRecord directly and delete fromArray if nothing external needs it. The whiteboard is wiped clean; only label cards remain.

Step 6: Move behaviour onto the new class. Hunt for functions that take the record and compute things from it — isSenior, idCardLine — and move them in as methods. This step is what turns a mere data bundle into a real object.

Figure 5: The migration as a state machine — the program compiles and runs in every state
⚠️

The most dangerous moment in this refactoring is the type conversion. In the array, the class was the string "7"; in the object it becomes the number 7. If some caller secretly depended on string behaviour (like joining "7" into a message), it may behave differently. Do the conversion in exactly one place, run your tests after every single caller you migrate, and never change all callers in one giant leap.

Here is the difference in conversation style between caller and data, before and after. Before, the caller interrogates a mute array and supplies all the meaning itself. After, the object answers by name:

Figure 6: Before, the caller carries the meaning; after, the meaning travels with the data

College corner: notice that steps 1–4 keep two representations alive at once, bridged by the fromArray factory. This is a general migration pattern called an adapter at the boundary — the same trick used for database schema migrations and API version upgrades. The old format and new format coexist; traffic moves over one caller at a time; the bridge is deleted last. If anything goes wrong, you have a tiny, revertible change instead of a forty-file catastrophe. Professional teams call the giant-leap alternative a "big bang migration", and they say it with the same tone Dadi uses for "seven rotis".

🏏 A bigger real-life example

Ravi's elder cousin Neha studies computer engineering and writes the software for a local cricket academy in Nagpur. When she visits the Sharma house and hears the seven-roti story, she laughs — and then goes quiet, because she has just realised her league table code has the exact same disease. The old code stores each team as an array, exactly the way a spreadsheet row looks:

// BEFORE
// row[0] = team name, row[1] = wins, row[2] = losses, row[3] = points (all strings!)
const table: string[][] = [
  ["Nagpur Strikers", "12", "4", "24"],
  ["Wardha Warriors", "9", "7", "18"],
  ["Amravati Aces", "9", "7", "19"],   // bug: points should be 18, who will notice?
];
 
function printTable(table: string[][]): void {
  for (const row of table) {
    const rate = Number(row[1]) / (Number(row[1]) + Number(row[2]));
    console.log(`${row[0]}: ${row[3]} pts (win rate ${rate.toFixed(2)})`);
  }
}
 
function topTeam(table: string[][]): string {
  let best = table[0];
  for (const row of table) {
    if (Number(row[3]) > Number(best[3])) best = row;
  }
  return best[0];
}

Every function repeats the same Number(...) conversions. Every function re-remembers the column order. The bug on the Amravati row (points that disagree with the wins) sits quietly, because nothing in the structure says "points must equal wins times two". Last season, that exact kind of row pushed the wrong team into the playoffs, and coach Pravin had to apologise to two angry team captains. Nobody could even say when the bad number was typed in.

After Replace Array with Object:

// AFTER
class TeamStanding {
  constructor(
    public readonly name: string,
    public readonly wins: number,
    public readonly losses: number,
  ) {
    if (wins < 0 || losses < 0) {
      throw new Error("Wins and losses cannot be negative");
    }
  }
 
  get points(): number {
    return this.wins * 2;          // derived, so it can NEVER disagree with wins
  }
 
  get winRate(): number {
    const played = this.wins + this.losses;
    return played === 0 ? 0 : this.wins / played;
  }
 
  summary(): string {
    return `${this.name}: ${this.points} pts (win rate ${this.winRate.toFixed(2)})`;
  }
}
 
const table: TeamStanding[] = [
  new TeamStanding("Nagpur Strikers", 12, 4),
  new TeamStanding("Wardha Warriors", 9, 7),
  new TeamStanding("Amravati Aces", 9, 7),   // points are computed: bug impossible
];
 
function printTable(table: TeamStanding[]): void {
  for (const team of table) console.log(team.summary());
}
 
function topTeam(table: TeamStanding[]): string {
  return table.reduce((best, t) => (t.points > best.points ? t : best)).name;
}

Look closely at what improved:

  • The outer array stayed an array — and rightly so, because it is a true list of similar things (teams). Only the inner impostor array became an object.
  • The points column vanished entirely. It was derived data, and as a getter it can never drift out of sync with wins. The Amravati bug is not just fixed; it is unrepresentable.
  • All the Number(...) noise disappeared. Types are right from the moment of creation.
  • The constructor now validates. An array slot could hold "-5" happily; the object refuses.

College corner: "the bug is unrepresentable" is the key phrase. The points column was a denormalised copy of information that already existed in wins — and any duplicated fact can drift from its source. By turning points into a derived getter, Neha enforced an invariant: at every moment, for every team, points equal wins times two. Invariants you compute cannot be violated; invariants you merely document get violated on a Friday evening before a deadline. This idea — "make illegal states unrepresentable" — comes from the functional programming world but applies in every language you will ever use.

After the rewrite, Neha tracked her academy bug reports for a term. The chart she showed coach Pravin looked like this:

Figure 7: Index and column mix-up bugs at the academy, before and after the refactoring

Six bugs a month sounds dramatic, but each one was tiny and humiliating: a swapped column in a new report, a forgotten Number(...) making "9" + "7" print as 97, a new column inserted in the middle shifting every index after it. All six belonged to the same family, and the whole family went extinct at once, because the disease they shared — positional meaning — no longer existed.

When the academy committee asked Neha why the old table kept breaking, she ran a quick survey of everyone who had touched the code: "what did you think row[1] meant?" The answers explain everything:

Figure 8: What different developers believed slot 1 meant — everyone was confident, most were wrong

Forty percent were right. The rest were Dadi with seven rotis — careful people misled by a format that refuses to explain itself.

🐍 A quick look in Python

The same refactoring exists in every language. In Python, the "before" usually looks like a list or tuple being unpacked by position, and the "after" is a dataclass — Python's lightweight way to declare named, typed fields:

# BEFORE: positional bundle, all meaning is in your memory
row = ["Nagpur Strikers", "12", "4"]
rate = int(row[1]) / (int(row[1]) + int(row[2]))
 
# AFTER: a dataclass with named, typed fields
from dataclasses import dataclass
 
@dataclass(frozen=True)
class TeamStanding:
    name: str
    wins: int
    losses: int
 
    @property
    def points(self) -> int:
        return self.wins * 2
 
    @property
    def win_rate(self) -> float:
        played = self.wins + self.losses
        return 0.0 if played == 0 else self.wins / played
 
team = TeamStanding(name="Nagpur Strikers", wins=12, losses=4)
print(f"{team.name}: {team.points} pts ({team.win_rate:.2f})")

Two details worth noticing. frozen=True makes the object immutable, like readonly fields in TypeScript — once built, a standing cannot be quietly edited. And the keyword arguments at the construction site (wins=12, losses=4) make the call read exactly like the label card on the fridge. Python programmers who skip this and pass tuples around eventually meet their own seven-roti morning.

🟦 The same refactoring in C#

C# gives us several pleasant shapes for the result. The most modern and compact is a record, which gives value equality and readable printing for free:

// BEFORE: the impostor array
// row[0] = name, row[1] = wins, row[2] = losses
string[] row = { "Nagpur Strikers", "12", "4" };
double rate = double.Parse(row[1]) / (double.Parse(row[1]) + double.Parse(row[2]));
// AFTER: a record with named, typed members
public record TeamStanding(string Name, int Wins, int Losses)
{
    public int Points => Wins * 2;
 
    public double WinRate =>
        (Wins + Losses) == 0 ? 0 : (double)Wins / (Wins + Losses);
 
    public string Summary() => $"{Name}: {Points} pts (win rate {WinRate:F2})";
}
 
var team = new TeamStanding("Nagpur Strikers", 12, 4);
Console.WriteLine(team.Summary());   // Nagpur Strikers: 24 pts (win rate 0.75)

A few C#-specific notes worth teaching yourself early:

  • int Wins is a real integer. The string-parsing ritual (double.Parse(row[1])) happens once, at the boundary where data enters — for example, in a single FromCsvRow factory method — and never again.
  • The record's positional constructor still has an order (Name, then Wins, then Losses), but the compiler checks the types, and you can use named arguments — new TeamStanding(Name: "Aces", Wins: 9, Losses: 7) — to make call sites read like the fridge label card.
  • Points is an expression-bodied property: derived data computed on demand, exactly like the TypeScript getter.
  • If the data must change after creation (mutable wins count during a live match), prefer a class with methods like RecordWin() rather than letting outsiders set fields — which is the subject of the next lesson, Encapsulate Field.

🧰 IDE support

There is no single one-click "Replace Array with Object" button in mainstream IDEs, because the IDE cannot guess what each slot means — only you know that slot 1 is "wins". But the IDE automates almost every individual step:

  • Visual Studio / Rider / IntelliJ IDEA: create the new class quickly with generate constructor and generate properties actions. In Rider and IntelliJ, Alt+Enter on usage of a not-yet-existing class offers to create it for you.
  • Find Usages (Shift+F12 in Visual Studio, Alt+F7 in JetBrains IDEs) lists every place the array is indexed, giving you a precise checklist of call sites to migrate one by one.
  • Rename refactoring (F2 in VS Code, Shift+F6 in JetBrains IDEs) safely renames the new fields later if you choose better names — something that was impossible when the "field" was just the number 1.
  • TypeScript's compiler is your refactoring partner: change a function's parameter from string[] to StudentRecord and the compiler instantly lists every caller that still passes an array. Fix them one at a time, and when the error list is empty, the migration is complete.

The pattern to remember: the IDE does the mechanical typing; you supply the meaning.

⚖️ Benefits and risks

BenefitsRisks / Costs
Every value gets a meaningful name — team.wins instead of row[1]A new class to write and maintain
Each field gets its own correct type; no more strings pretending to be numbersAt I/O boundaries (CSV, JSON, network), you must write mapping code to and from positional form
Whole families of bugs vanish: wrong index, off-by-one, transposed columnsWrong on a true homogeneous list — do not apply it to a genuine list of similar items
The object becomes a home for behaviour (winRate(), validation, derived values)For a tiny throwaway pair used in one function, a tuple may be lighter than a full class
The compiler and IDE can check, rename, and navigate field accessType conversions during migration can subtly change behaviour if rushed

🧪 Which smells does it cure?

SmellHow Replace Array with Object helps
Primitive ObsessionThe raw array standing in for a structured value becomes a proper type with named, validated fields
Data ClassPartially — the new object starts as data, but step 6 (moving behaviour in) prevents it becoming a hollow data bag
Duplicate CodeRepeated index-decoding and string-to-number conversion in many functions collapses into one place
CommentsThe decoder-ring comments (// [1] = wins) become unnecessary and get deleted

Everything we covered fits on one mental map. If you remember only this picture, you remember the whole lesson:

Figure 9: The whole refactoring on one map

📦 Quick revision box

+================================================================+
|          REPLACE ARRAY WITH OBJECT — REVISION CARD             |
+================================================================+
| SMELL SIGN : row[0], row[1], row[2] mean DIFFERENT things      |
| TEST       : "Would shuffling destroy the meaning?"            |
|              YES -> it is a record in disguise -> refactor     |
|              NO  -> true list of similar items -> leave it     |
+----------------------------------------------------------------+
| THE MOVE   : 1. Create empty class                             |
|              2. Add one named, typed field per slot            |
|              3. Migrate readers slot by slot (test each step)  |
|              4. Delete the array form                          |
|              5. Move related behaviour into the class          |
+----------------------------------------------------------------+
| REMEMBER   : Convert types at ONE boundary only                |
|              Derived data (points) -> computed getter          |
|              Fridge whiteboard -> label card                   |
+================================================================+

✍️ Practice exercise

A school canteen tracks each day's sales like this:

// sale[0] = item name, sale[1] = price in rupees (string),
// sale[2] = quantity sold (string), sale[3] = "veg" or "nonveg"
const sales: string[][] = [
  ["Samosa", "15", "120", "veg"],
  ["Vada Pav", "20", "95", "veg"],
  ["Egg Roll", "40", "30", "nonveg"],
];
 
function totalRevenue(sales: string[][]): number {
  let total = 0;
  for (const sale of sales) {
    total += Number(sale[1]) * Number(sale[2]);
  }
  return total;
}
 
function vegItems(sales: string[][]): string[] {
  return sales.filter((s) => s[3] === "veg").map((s) => s[0]);
}

Your tasks:

  1. Apply Replace Array with Object. Create a CanteenSale class with properly named and typed fields (price and quantity must be numbers; consider a union type "veg" | "nonveg" for the food type).
  2. Migrate totalRevenue and vegItems step by step — keep the program compiling after every change.
  3. Add a revenue getter to CanteenSale so the multiplication lives with the data.
  4. Add validation in the constructor: price and quantity must not be negative.
  5. Bonus: write a fromCsvRow(row: string[]) factory so positional data from a real CSV file is converted in exactly one place.
  6. Reflection question: the outer sales array — should it also become an object? Why or why not? (Hint: apply the shuffle test.)

And one last thought to carry with you. The whiteboard format was not evil — it was cheap. Deepak saved ten seconds by not writing labels, and the family paid for those ten seconds with phone calls, seven rotis, and a nickname that followed Ravi to class 8. Arrays with secret positions make exactly that trade: a few seconds saved at writing time, paid back with interest by every reader, forever. Write the labels. Future readers — including you, three months from now — are the family reading your fridge.

Frequently asked questions

Is every array bad? Should I replace all arrays with objects?
No, not at all! Arrays are perfect when every slot holds the same kind of thing — a list of marks, a list of student names, a series of temperatures. The problem appears only when slot 0 means one thing (a name) and slot 1 means a totally different thing (a class number). That array is pretending to be a record, and only that kind of array needs this refactoring.
How is this different from Replace Data Value with Object?
Replace Data Value with Object upgrades a single plain value (like a string holding a phone number) into a proper type. Replace Array with Object upgrades a whole bundle of mixed values that were squeezed into array slots. Same spirit — give data a real shape — but one works on a single value and the other on a positional bundle.
Can I just use a TypeScript tuple like [string, number] instead of a class?
A typed tuple is better than a plain array because the compiler checks types per position. But the positions still have no names — readers must still remember what index 1 means. For data used in one small place, a tuple is fine. For data that travels across the program, named fields in an object or class are much clearer.
What about data coming from CSV files or APIs that really is positional?
Convert it at the boundary. The moment a CSV row enters your program, turn it into a proper object in one mapping function. The rest of your code then works with named fields. When you need to write data out again, one function converts the object back into the positional form. Keep the cryptic format at the edges only.
After making the object, my class has only data and no methods. Is that okay?
It is a good first step, but watch out — a class with only data is the Data Class smell. Look at the code around it. Any calculation that uses the object's fields (like computing a percentage from marks) probably belongs inside the object as a method. Move it in, and the object becomes truly useful.

Further reading

Related Lessons