Arrays of Objects in TypeScript: Declare, Type and Use Them

An array of objects in TypeScript is declared by describing one object and adding []: const products: Product[] = [...], where Product is an interface or a type alias. Array<Product> means the same thing. Once the array is typed, the compiler checks every property you write and every property you read, which is the whole point. This guide covers declaring and initializing arrays of objects, starting one empty, readonly arrays, nested and optional properties, typing data that arrives from an API, and the excess property rule that confuses almost everyone.

Every example was compiled and run with TypeScript 5.2.2 and Node.js v22.22.2 in the Windows Command Prompt, with tsc --target es2022 --lib es2023,dom --strict file.ts and then node file.js. The output is the real output. Reference: Object Types in the TypeScript Handbook.

Declare an array of objects in TypeScript

Describe the object once, then use it as the element type. Both forms below are identical; Product[] is the common style and Array<Product> reads better when the element type is itself generic:

interface Product {
  id: number;
  name: string;
  price: number;
}

const products: Product[] = [
  { id: 1, name: "Keyboard", price: 49.99 },
  { id: 2, name: "Monitor", price: 229 },
];

console.log(products.length, products[0].name);

// Array<Product> means exactly the same thing
const more: Array<Product> = [{ id: 3, name: "Webcam", price: 39.5 }];
console.log(more[0].price);

// the compiler knows every property: products[0].nmae would not compile
console.log(products.map(product => `${product.name}: $${product.price.toFixed(2)}`));

Output:

2 Keyboard
39.5
[ 'Keyboard: $49.99', 'Monitor: $229.00' ]
Command Prompt showing a TypeScript Product interface used to type an array of objects, compiled with tsc and run with node
One interface, two ways to write the array type, and full checking on every property.
You wantWrite
An array of a named shapeconst rows: Order[] = [...]
The same, generic formconst rows: Array<Order> = [...]
A one-off shapeconst rows: { id: string }[] = [...]
An empty array to fill laterconst rows: Order[] = []
An array nobody can changeconst rows: readonly Order[] = [...]
Fixed values, frozenconst rows = [...] as const
Groups of rowsRecord<string, Order[]>

Interface, type alias or an inline type?

For data shapes the two are interchangeable, so pick one and be consistent: interfaces are the convention for objects that describe data, while a type alias can also express unions, which an interface cannot. Inline types are fine for a single small array and unreadable once they grow. There is a focused guide to defining an interface for an array of objects if that is all you need:

// 1. an interface: the usual choice for data shapes
interface Order {
  id: string;
  total: number;
}

// 2. a type alias: identical here, and it can also describe unions
type Customer = {
  name: string;
  city: "Austin" | "Denver" | "Seattle";     // only these three values
};

// 3. an inline type: fine for a one-off, unreadable once it grows
const flags: { key: string; enabled: boolean }[] = [{ key: "beta", enabled: true }];

const orders: Order[] = [{ id: "ORD-1001", total: 249.99 }];
const customers: Customer[] = [{ name: "Emma", city: "Austin" }];

console.log(orders[0].id, customers[0].city, flags[0].enabled);

// a union type lets one array hold two shapes, told apart by a common field
type Row = { kind: "order"; id: string } | { kind: "note"; text: string };
const feed: Row[] = [{ kind: "order", id: "ORD-1002" }, { kind: "note", text: "called back" }];

for (const row of feed) {
  console.log(row.kind === "order" ? `order ${row.id}` : `note: ${row.text}`);
}

Output:

ORD-1001 Austin true
order ORD-1002
note: called back

The last example is a discriminated union: one array holding two different shapes, told apart by a kind field. TypeScript narrows each row inside the loop, so only the properties that exist on that shape are available.

Start with an empty array of objects

Write the type on the variable, not on the value: const orders: Order[] = []. Leave it out and TypeScript builds the type from whatever you push in, which usually ends up wider than you wanted. The same applies in React, where useState<Order[]>([]) is the equivalent, and to initializing arrays in general:

interface Order {
  id: string;
  total: number;
}

// the right way to start empty: say what will go in it
const orders: Order[] = [];
orders.push({ id: "ORD-1001", total: 249.99 });
console.log(orders.length, orders[0].id);

// without the type, TypeScript works it out from what you push in
const guessed = [];
guessed.push({ id: "ORD-1002" });
guessed.push({ id: "ORD-1003", total: 89.5 });
console.log(guessed);

// same idea for a value that arrives later
let selected: Order | null = null;
selected = orders[0] ?? null;
console.log(selected?.id ?? "nothing selected");

Output:

1 ORD-1001
[ { id: 'ORD-1002' }, { id: 'ORD-1003', total: 89.5 } ]
ORD-1001
Command Prompt output showing a typed empty array of objects being filled with push and an untyped array whose type is inferred from its contents
The typed array only accepts complete orders; the untyped one takes whatever it is given.

The excess property rule that catches everyone

Add a property that is not on the interface and TypeScript rejects it, but only when you write the object directly in the array. This is called the excess property check, and it exists to catch typos in object literals:

interface Order {
  id: string;
  total: number;
}

const orders: Order[] = [
  { id: "ORD-1001", total: 249.99, courier: "UPS" },   // "courier" is not on Order
];

console.log(orders.length);

tsc output:

excess_property.ts(7,36): error TS2322: Type '{ id: string; total: number; courier: string; }' is not assignable to type 'Order'.
  Object literal may only specify known properties, and 'courier' does not exist in type 'Order'.
Command Prompt showing TypeScript error TS2322 because an object literal in an array has a property that is not on the interface
Error TS2322: courier is not a property of Order.

Now build exactly the same object in a variable first and the compiler accepts it. Nothing is wrong with your interface: the check simply does not apply to a value that arrives through a variable, because TypeScript only requires the object to be compatible, not identical:

interface Order {
  id: string;
  total: number;
}

// the same object, but built first and then added: this COMPILES
const draft = { id: "ORD-1001", total: 249.99, courier: "UPS" };
const orders: Order[] = [draft];

console.log(orders.length, JSON.stringify(orders[0]));

// the extra property is still there at runtime, it is just invisible to the type
console.log("courier" in orders[0], (orders[0] as typeof draft).courier);

Output:

1 {"id":"ORD-1001","total":249.99,"courier":"UPS"}
true UPS
Command Prompt output showing that assigning a variable with an extra property to a typed array compiles and the property survives at runtime
The same object, accepted, with courier still on it at runtime.

That last line matters when you send the array back to an API: extra properties are invisible to the type system but very much present in the JSON.

Readonly arrays of objects

readonly Order[] stops anything being added, removed or reordered, which makes it the right type for a function parameter you promise not to touch. It does not freeze the objects themselves; Readonly<Order> does that, and as const locks a literal completely. More on the array side in readonly arrays in TypeScript:

interface Region {
  code: string;
  name: string;
}

// nobody can add, remove or reorder these
const regions: readonly Region[] = [
  { code: "TX", name: "Texas" },
  { code: "CO", name: "Colorado" },
];

console.log(regions.map(region => region.code));
// regions.push(...) does not compile: readonly arrays have no push

// but the OBJECTS inside are still editable unless you say otherwise
const locked: readonly Readonly<Region>[] = regions;
console.log(locked[0].name);

// as const freezes the whole literal, values included
const statuses = [{ id: 1, label: "pending" }, { id: 2, label: "shipped" }] as const;
console.log(statuses[1].label);

Output:

[ 'TX', 'CO' ]
Texas
shipped

Try to change one and the compiler stops you before anything runs:

interface Region {
  code: string;
}

const regions: readonly Region[] = [{ code: "TX" }];
regions.push({ code: "CO" });

tsc output:

readonly_push_objects.ts(6,9): error TS2339: Property 'push' does not exist on type 'readonly Region[]'.

Nested objects, optional properties and grouping

Real data nests: an order has a customer, and the order has line items which are themselves objects. Mark what may be missing with ? and read it with ?. or ??. To group rows, Record<string, Order[]> describes an object whose values are arrays of objects:

interface LineItem {
  sku: string;
  quantity: number;
}

interface Order {
  id: string;
  customer: { name: string; email?: string };     // a nested object
  items: LineItem[];                               // an array inside the object
  notes?: string[];                                // optional array
}

const orders: Order[] = [
  { id: "ORD-1001", customer: { name: "Emma Johnson" },
    items: [{ sku: "KB-1", quantity: 2 }, { sku: "MN-9", quantity: 1 }] },
  { id: "ORD-1002", customer: { name: "Michael Brown", email: "m@example.com" },
    items: [{ sku: "WC-3", quantity: 1 }], notes: ["gift wrap"] },
];

for (const order of orders) {
  const units = order.items.reduce((sum, item) => sum + item.quantity, 0);
  console.log(`${order.id} ${order.customer.name} ${units} units ${order.customer.email ?? "(no email)"}`);
}

// grouping them: an object whose values are arrays of objects
const byCity: Record<string, Order[]> = { Austin: [orders[0]], Denver: [orders[1]] };
console.log(Object.entries(byCity).map(([city, list]) => `${city}: ${list.length}`));

Output:

ORD-1001 Emma Johnson 3 units (no email)
ORD-1002 Michael Brown 1 units m@example.com
[ 'Austin: 1', 'Denver: 1' ]

Typing data that comes from an API

JSON.parse() returns any, so a cast makes the compiler happy and proves nothing. If the API changes a field, the cast hides it until something breaks at runtime. Validating once at the boundary is a few lines, and everything downstream is then genuinely typed:

interface Order {
  id: string;
  total: number;
}

// note the third row: the API sent the total as text
const payload = '[{"id":"ORD-1001","total":249.99},{"id":"ORD-1002","total":89.5},{"id":"ORD-1003","total":"free"}]';

// JSON.parse returns any, so this compiles even when the data is wrong
const risky = JSON.parse(payload) as Order[];
console.log(risky[0].id);

// checking it once is cheap, and everything after this point is genuinely typed
const isOrder = (value: unknown): value is Order =>
  typeof value === "object" && value !== null &&
  typeof (value as Order).id === "string" && typeof (value as Order).total === "number";

const parsed: unknown = JSON.parse(payload);
const orders: Order[] = Array.isArray(parsed) ? parsed.filter(isOrder) : [];

console.log(orders.length, "valid orders");
console.log(Array.isArray(parsed) ? parsed.length - orders.length : 0, "rows rejected");

// satisfies checks a literal against a type without widening it
const defaults = [