The TypeScript Record vs object choice is simple: Record<K, V> builds an object type from a key type and one value type, while a plain object type lists named properties with their own types.
So reach for Record for lookup tables, and an object type or interface for a fixed shape:
type Rates = Record<"standard" | "express", number>; // every key, same value type
type Order = { id: string; total: number; shipped: boolean }; // named fields
In this tutorial, I’ll compare the two on real code: key checks, index signatures, the lowercase object type, interfaces, and runtime behavior. Each example ran under TypeScript 7.0.2 and Node.js v22.22.2 with strict mode on, and the screenshots come straight from VS Code.
What is an object type in TypeScript?
An object type describes one known shape. You write it as a type literal or an interface, and every property can have a different type. An order has a string ID, a number total, a boolean flag and a date.
A Record works the other way around. You give it the set of keys and a single value type, and TypeScript expands it into the matching object type. If you’re new to the utility itself, start with my TypeScript Record type guide.
Here are both side by side in one file:
type Order = {
id: string;
customer: string;
total: number;
shipped: boolean;
placedOn: Date;
};
type Tier = "standard" | "express" | "overnight";
type ShippingRates = Record<Tier, number>;
const order: Order = {
id: "ORD-1042",
customer: "Megan Bowen",
total: 149.99,
shipped: false,
placedOn: new Date(2026, 9, 27),
};
const rates: ShippingRates = {
standard: 5.99,
express: 14.99,
overnight: 29.99,
};
const when = order.placedOn.toLocaleDateString("en-US");
console.log(`${order.id} for ${order.customer} on ${when}`);
console.log(`order total: $${order.total.toFixed(2)}`);
for (const [tier, price] of Object.entries(rates)) {
console.log(`${tier.padEnd(10)} $${price.toFixed(2)}`);
}
Output:
ORD-1042 for Megan Bowen on 10/27/2026
order total: $149.99
standard $5.99
express $14.99
overnight $29.99

Order type mixes strings, numbers, a boolean and a date, while the Record maps three shipping tiers to prices.You couldn’t write Order as a Record, since its values don’t share one type. You could write ShippingRates as a type literal, but you’d repeat number for every tier.
How does Record check keys in TypeScript?
When the key type is a union of string literals, Record becomes strict. Every key in the union is required, and any key outside it is rejected. That’s the biggest practical win over a loose object.
I left out west in Q3 and added an extra pacific key in Q4:
type Region = "northeast" | "south" | "midwest" | "west";
// Q3 2026 sales in USD, one entry per region
const q3Sales: Record<Region, number> = {
northeast: 48210.5,
south: 61875.25,
midwest: 39940.0,
};
const q4Sales: Record<Region, number> = {
northeast: 50100.0,
south: 63450.75,
midwest: 41200.0,
west: 57325.4,
pacific: 12000.0,
};
Compiler output:
rec_missing.ts(4,7): error TS2741: Property 'west' is missing in type '{ northeast: number; south: number; midwest: number; }' but required in type 'Record<Region, number>'.
rec_missing.ts(15,3): error TS2353: Object literal may only specify known properties, and 'pacific' does not exist in type 'Record<Region, number>'.

q3Sales for the missing west region, and the terminal flags the extra pacific key.Add a fifth region to the union later, and every Record built from it fails until you fill in the new key. Where the keys come from an existing type, the keyof operator in TypeScript gives you that union for free.
Record vs an index signature
With a plain string key, Record<string, number> is the same type as { [state: string]: number }. You can assign one to the other without a cast. The TypeScript index signatures tutorial covers the longer syntax.
Both share one trap: a lookup is typed as number even when the key doesn’t exist. Texas isn’t in my table:
type TaxTable = Record<string, number>;
type TaxTable2 = { [state: string]: number };
const salesTax: TaxTable = { CA: 0.0725, NY: 0.04, WA: 0.065 };
const sameShape: TaxTable2 = salesTax; // fully interchangeable
const texas = salesTax["TX"]; // typed as number
console.log("TX rate:", texas);
console.log("TX tax on $80.00:", (80 * texas).toFixed(2));
const rate = salesTax["TX"] ?? 0;
console.log("TX tax with ?? 0:", (80 * rate).toFixed(2));
console.log("CA tax on $80.00:", (80 * sameShape["CA"]).toFixed(2));
Output:
TX rate: undefined
TX tax on $80.00: NaN
TX tax with ?? 0: 0.00
CA tax on $80.00: 5.80
The type said number, the runtime said undefined, and the math produced NaN. A default with ?? fixes it. You can also turn on the noUncheckedIndexedAccess compiler option, which adds undefined to every lookup:
const salesTax: Record<string, number> = { CA: 0.0725, NY: 0.04 };
const texas: number = salesTax["TX"];
Compiler output with –noUncheckedIndexedAccess:
rec_nuia.ts(3,7): error TS2322: Type 'number | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.
One difference does favor the index signature. An interface can mix named members with it, as long as each named member fits the index type. For a shape like that, an interface reads more clearly than an intersection with Record.
Why isn’t the lowercase object type the same as Record?
Lowercase object means any non-primitive value. It accepts plain objects, arrays and functions, and it says nothing about keys, so you can’t read properties through it. The {} type is even looser.
const cart = { coffee: 12.5, mug: 18.0, beans: 15.75 };
function cartTotal(prices: object): number {
let sum = 0;
for (const item in prices) {
sum += prices[item];
}
return sum;
}
const alsoAnObject: object = [1, 2, 3]; // arrays pass too
console.log(cartTotal(cart), alsoAnObject);
Compiler output:
rec_objtype.ts(6,12): error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
No index signature with a parameter of type 'string' was found on type '{}'.
The compiler won’t index into object, yet it happily accepts an array for it. Swapping the parameter for Record<string, number> fixes both problems. I go deeper on the {} type in the TypeScript empty object type guide.
const cart = { coffee: 12.5, mug: 18.0, beans: 15.75 };
function cartTotal(prices: Record<string, number>): number {
let sum = 0;
for (const item in prices) {
sum += prices[item];
}
return sum;
}
console.log(`cart total: $${cartTotal(cart).toFixed(2)}`);
Output:
cart total: $46.25
So if you mean “an object with string keys,” write Record<string, unknown> or a typed Record, not object.
Why won’t an interface pass as Record?
This one confuses a lot of developers. Two types with the same fields behave differently. A type alias for an object literal gets an implicit index signature, but an interface doesn’t, because interfaces can be extended later.
interface CustomerI {
name: string;
city: string;
}
type CustomerT = {
name: string;
city: string;
};
function logRow(row: Record<string, unknown>): void {
for (const [field, value] of Object.entries(row)) {
console.log(`${field}: ${String(value)}`);
}
}
const fromType: CustomerT = { name: "Alex Wilber", city: "Seattle" };
const fromIface: CustomerI = { name: "Adele Vance", city: "Denver" };
logRow(fromType); // OK
logRow(fromIface); // error
Compiler output:
rec_iface.ts(21,8): error TS2345: Argument of type 'CustomerI' is not assignable to parameter of type 'Record<string, unknown>'.
Index signature for type 'string' is missing in type 'CustomerI'.

You have three fixes: switch to a type alias, add [key: string]: unknown to the interface, or widen the parameter to object when you only need Object.entries(). My TypeScript type vs interface guide explains when each one fits.
Is there a runtime difference between Record and object?
No. Record exists only in the type system, and the compiler erases it. Both values below are plain JavaScript objects with the same JSON:
type Region = "northeast" | "south" | "midwest" | "west";
const viaRecord: Record<Region, number> = {
northeast: 48210.5, south: 61875.25, midwest: 39940.0, west: 57325.4,
};
const viaObject: { northeast: number; south: number;
midwest: number; west: number } = { ...viaRecord };
console.log("typeof:", typeof viaRecord, "/", typeof viaObject);
console.log("same JSON:",
JSON.stringify(viaRecord) === JSON.stringify(viaObject));
const regions = Object.keys(viaRecord) as Region[];
const total = Object.values(viaRecord).reduce((a, b) => a + b, 0);
console.log("regions:", regions.join(", "));
console.log(`total Q3: $${total.toFixed(2)}`);
Output:
typeof: object / object
same JSON: true
regions: northeast, south, midwest, west
total Q3: $207351.15
Object.keys() returns string[] for both, so I cast it back to Region[] when the keys are known. For more loop patterns, see how to iterate over a Record in TypeScript.
If you need real runtime behavior, like key order, a size property or non-string keys, that’s a Map. I compare those in Record vs Map in TypeScript.
Build a Partial Record step by step
Grouping data is where Record earns its place. Not every state has orders yet, so Partial makes every key optional. To try it in VS Code yourself:
- Create a folder, open it in VS Code, and run
npm install -D typescriptin the terminal (Terminal > New Terminal). - Run
npx tsc --initand set"strict": trueintsconfig.json. - Save the code below as
rec_partial.ts. - Run
npx tsc, thennode rec_partial.js, and compare the totals.
type State = "CA" | "NY" | "TX" | "WA";
const orders: { id: string; state: State; amount: number }[] = [
{ id: "A-101", state: "CA", amount: 89.99 },
{ id: "A-102", state: "TX", amount: 42.5 },
{ id: "A-103", state: "CA", amount: 120.0 },
{ id: "A-104", state: "WA", amount: 15.25 },
];
// Not every state has orders yet, so every key is optional
const byState: Partial<Record<State, number>> = {};
for (const o of orders) {
byState[o.