TypeScript `object` vs `{}` vs `Record<string, unknown>`: Which Type Actually Means What You Think
Most TypeScript type bugs stem from confusion between `object`, `{}`, and `Record<string, unknown>`. Each accepts radically different values. This post explains the actual behavior and shows when to use each type in production code.
Most TypeScript object type bugs stem from choosing object, {}, or Record<string, unknown> based on intuition rather than actual behavior. Developers reach for {} expecting "any object", only to discover it accepts strings and numbers. Teams use object for API responses, then hit runtime crashes when the payload contains null. Engineers type dictionaries as Record<string, any> and lose all safety, or choose Record<string, unknown> and spend hours fighting legitimate property access.
The three types look like they mean the same thing. They do not. Each accepts a completely different set of values. The gap between intent and reality creates bugs that pass type checking but fail in production.
flowchart LR
A("Developer writes function expecting object payload")
B("Chooses {} as parameter type")
C("Type checker accepts string argument")
D("Runtime crashes accessing .properties")
A --> B
B --> C
C --> D
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
The correct approach requires matching the type to the actual constraint. Use object to exclude primitives but allow any non-null object structure. Use Record<string, unknown> when the value must be a dictionary with string keys. Never use {} unless the function genuinely accepts all non-null values including primitives.
flowchart LR
A("Developer writes function expecting object payload")
B("Chooses object as parameter type")
C("Type checker rejects string argument")
D("Runtime receives only valid objects")
A --> B
B --> C
C --> D
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- The
objecttype excludes all primitives but accepts any object, array, function, or class instance: not just plain objects. - The empty object type
{}is the most permissive: it accepts everything exceptnullandundefined, including strings, numbers, and booleans. Record<string, unknown>enforces dictionary structure with string keys but requires explicit property checks before access.- Most "accept any object" scenarios require
object, not{}orRecord<string, unknown>. - Runtime crashes from wrong object types usually trace back to choosing based on what the syntax looks like rather than what values it permits.
Understanding TypeScript's object Type: What It Actually Accepts
The object type represents any value that is not a primitive. TypeScript considers string, number, boolean, symbol, bigint, null, and undefined to be primitives. Everything else passes the object constraint: plain objects, arrays, functions, Date instances, class instances, and regular expressions.
%% alt: Flowchart showing how TypeScript's object type classifies different value categories
flowchart TD
A("Value passed to object-typed parameter")
B("Is it null or undefined?")
C("Is it a primitive: string, number, boolean, symbol, bigint?")
D("Type error: object excludes null and undefined")
E("Type error: object excludes primitives")
F("Type passes: arrays, functions, Date, class instances, plain objects all satisfy object")
A --> B
B -->|Yes| D
B -->|No| C
C -->|Yes| E
C -->|No| F
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
style E stroke:#ef4444,fill:#450a0a,color:#fca5a5
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This matters because developers often use object when they mean "plain object literal". The type checker accepts an array or function, but the runtime logic assumes property access will work. The function crashes when the argument turns out to be [] or () => {}.
function logMetadata(data: object) {
// Type checker allows this call because arrays are objects
console.log(data.toString());
// Runtime error if data is an array: cannot access arbitrary properties
console.log(data.timestamp); // No type error, but undefined at runtime
}
logMetadata([1, 2, 3]); // Compiles fine, runtime behavior surprising
logMetadata(() => {}); // Also compiles, also surprisingThe implication here is that object works for scenarios where the constraint is "not a primitive" rather than "has properties I can enumerate". Serialization functions, cache keys, and WeakMap operations fit this pattern. API response validation does not.
When the function needs to access properties, the type must be more specific. Either constrain to a known interface, or use Record<string, unknown> to enforce dictionary structure. The object type signals "I need something that isn't a primitive, but I won't assume shape".
// Correct: object for cache keys where only identity matters
const cache = new WeakMap<object, string>();
cache.set({ id: 1 }, "value"); // Works
cache.set([1, 2, 3], "value"); // Also works, arrays are objects
cache.set("key", "value"); // Type error: string is primitive
// Incorrect: object for JSON responses expecting properties
function handleResponse(response: object) {
// Type checker allows this but runtime fails on non-plain objects
const keys = Object.keys(response);
}The object type is the right choice when the constraint is "exclude primitives and null", nothing more. Most API boundaries need stronger guarantees.
The Empty Object Type {}: The Most Permissive Type in TypeScript
The empty object type {} accepts every value except null and undefined. Strings, numbers, booleans, symbols, and bigints all pass. Objects, arrays, functions, and class instances also pass. The name is misleading. This is not "empty object literal type". This is "all non-nullish values type".
%% alt: Flowchart showing how the empty object type accepts almost all values
flowchart TD
A("Value passed to {}-typed parameter")
B("Is it null or undefined?")
C("Type passes: {} accepts primitives, objects, arrays, functions, everything non-nullish")
D("Type error: {} excludes only null and undefined")
A --> B
B -->|No| C
B -->|Yes| D
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
The reason is structural typing. An empty interface has zero required properties. Every value in JavaScript has at least zero properties. Therefore every non-nullish value satisfies {}. Primitives have prototype methods, which count as properties for type checking purposes.
function processData(input: {}) {
// All of these compile without error
processData(42);
processData("hello");
processData(true);
processData([1, 2, 3]);
processData({ key: "value" });
processData(() => {});
// Only these fail
processData(null); // Type error
processData(undefined); // Type error
}This distinction is critical. Developers see {} and assume it means "plain object with unknown properties". The type checker interprets it as "anything that exists". Functions that accept {} parameters will receive primitives in production. The runtime logic crashes when it tries to enumerate keys on a number.
function mergeObjects(target: {}, source: {}) {
// Caller can pass primitives, type checker allows it
return Object.assign(target, source);
}
mergeObjects(5, { key: "value" }); // Compiles, runtime creates new object
mergeObjects("base", { key: "value" }); // Compiles, returns object with keyThe failure mode here is subtle but expensive. The function compiles. Integration tests pass if they happen to use object literals. Production receives a string from an upstream service. The assignment logic silently does the wrong thing because Object.assign on a primitive creates a wrapper object that the caller did not expect.
In other words, {} is almost never the right type for function parameters. The only valid use case is signaling "I accept literally any value that isn't null or undefined, and I will not access properties on it". That scenario is rare. More often, developers want unknown for that constraint.
// Wrong: {} suggests object but accepts primitives
function stringify(value: {}): string {
return JSON.stringify(value);
}
// Right: unknown signals "any value, I will narrow before use"
function stringify(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
return JSON.stringify(value);
}The empty object type exists because TypeScript's structural system requires it to represent "no constraints except existence". Production code should avoid it as a parameter type. Use unknown for top-level flexibility, or constrain to the actual structure required.
Record<string, unknown>: Dictionary Types That Actually Mean Dictionaries
The Record<string, unknown> type enforces that the value is an object with string keys and any value type. Unlike object or {}, this type excludes arrays, functions, and primitives. It represents a plain object used as a dictionary or map.
%% alt: Flowchart showing how Record enforces dictionary structure
flowchart TD
A("Value passed to Record<string, unknown> parameter")
B("Is it an object?")
C("Does it have callable signature or numeric indexer?")
D("Type error: primitives and null excluded")
E("Type error: arrays and functions excluded")
F("Type passes: plain objects with string keys accepted")
A --> B
B -->|No| D
B -->|Yes| C
C -->|Yes| E
C -->|No| F
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This matters because configuration objects, HTTP headers, query parameters, and environment variable maps all follow dictionary structure. The function needs to iterate keys or check for arbitrary properties without knowing the full shape ahead of time.
function processConfig(config: Record<string, unknown>) {
// Type checker knows config is an object with string keys
const keys = Object.keys(config); // Works
const hasDebug = "debug" in config; // Works
// But accessing properties requires runtime checks
if (typeof config.timeout === "number") {
console.log(`Timeout: ${config.timeout}`);
}
}
processConfig({ timeout: 5000 }); // Works
processConfig([]); // Type error: arrays are not plain objects
processConfig(() => {}); // Type error: functions are not dictionariesThe implication here is that Record<string, unknown> provides stronger constraints than object but weaker than a defined interface. It says "I know this is a dictionary, but I don't know what keys exist". This is the right type for functions that merge configurations, proxy unknown payloads, or iterate over arbitrary key-value pairs.
The tradeoff is that property access is not free. Every property read from a Record<string, unknown> produces an unknown value. The code must narrow the type before use. This friction is intentional. It prevents the silent bugs that happen when the function assumes a property exists and has a specific type.
// Without Record: object allows arrays, no property safety
function extend(base: object, overrides: object) {
return { ...base, ...overrides };
}
// With Record: enforces dictionary structure, requires narrowing
function extend(
base: Record<string, unknown>,
overrides: Record<string, unknown>
): Record<string, unknown> {
return { ...base, ...overrides };
}
// Caller gets type safety on arguments
extend({ a: 1 }, { b: 2 }); // Works
extend([1, 2], { b: 2 }); // Type error on first argument
extend({ a: 1 }, [2, 3]); // Type error on second argumentThe failure mode here is verbose code. Developers avoid Record<string, unknown> because narrowing every property feels tedious. They reach for Record<string, any> or object instead, and lose the compile-time safety that would have caught the array argument.
In other words, Record<string, unknown> is the correct type when the function operates on dictionaries with unknown structure. The verbosity is the point. It forces explicit validation at the boundaries where unknowns enter the typed domain.
Code Examples: How Each Type Behaves With Primitives, Objects, and Edge Cases
The behavior difference becomes clear when testing each type against the same set of values. The following examples show what the type checker accepts and rejects for each object type.
// Testing object type
function testObject(value: object) {
console.log("Received:", value);
}
testObject({ key: "value" }); // ✓ Plain object
testObject([1, 2, 3]); // ✓ Arrays are objects
testObject(() => {}); // ✓ Functions are objects
testObject(new Date()); // ✓ Date instances are objects
testObject(/regex/); // ✓ RegExp instances are objects
testObject(42); // ✗ Type error: number is primitive
testObject("text"); // ✗ Type error: string is primitive
testObject(true); // ✗ Type error: boolean is primitive
testObject(null); // ✗ Type error: null is primitive
testObject(undefined); // ✗ Type error: undefined is primitiveThe object type excludes all primitives and null, but accepts every kind of object including functions and arrays. This makes it too permissive for most real-world use cases where the function expects to access properties.
// Testing empty object type {}
function testEmptyObject(value: {}) {
console.log("Received:", value);
}
testEmptyObject({ key: "value" }); // ✓ Plain object
testEmptyObject([1, 2, 3]); // ✓ Arrays
testEmptyObject(() => {}); // ✓ Functions
testEmptyObject(new Date()); // ✓ Date instances
testEmptyObject(/regex/); // ✓ RegExp instances
testEmptyObject(42); // ✓ Numbers have prototype methods
testEmptyObject("text"); // ✓ Strings have prototype methods
testEmptyObject(true); // ✓ Booleans have prototype methods
testEmptyObject(Symbol("sym")); // ✓ Symbols exist
testEmptyObject(null); // ✗ Type error: null is nullish
testEmptyObject(undefined); // ✗ Type error: undefined is nullishThe empty object type accepts everything except null and undefined. This includes all primitives. The name is actively misleading. Developers who use {} expecting "any object" will be surprised when their function receives a number.
// Testing Record<string, unknown>
function testRecord(value: Record<string, unknown>) {
console.log("Received:", value);
}
testRecord({ key: "value" }); // ✓ Plain object with string keys
testRecord({ a: 1, b: 2 }); // ✓ Multiple properties
testRecord({}); // ✓ Empty object is valid dictionary
testRecord([1, 2, 3]); // ✗ Type error: arrays have numeric indexer
testRecord(() => {}); // ✗ Type error: functions have callable signature
testRecord(new Date()); // ✗ Type error: Date is not a plain object
testRecord(/regex/); // ✗ Type error: RegExp is not a plain object
testRecord(42); // ✗ Type error: number is primitive
testRecord("text"); // ✗ Type error: string is primitive
testRecord(null); // ✗ Type error: null is not an object
testRecord(undefined); // ✗ Type error: undefined is not an objectThe Record<string, unknown> type enforces dictionary structure. It rejects primitives, arrays, functions, and built-in object types. Only plain objects pass. This is the strongest constraint of the three types, making it the safest choice when the function operates on key-value pairs.
The edge case that surprises developers is property access. All three types allow checking if a property exists, but only Record<string, unknown> makes the resulting type explicit:
function checkProperty(value: object) {
if ("timestamp" in value) {
// value is still just 'object', no property type information
console.log((value as any).timestamp); // Must cast to access
}
}
function checkPropertyRecord(value: Record<string, unknown>) {
if ("timestamp" in value) {
// value.timestamp is 'unknown', must narrow
const ts = value.timestamp;
if (typeof ts === "number") {
console.log(ts); // Now safe to use
}
}
}The Record<string, unknown> approach requires more code but prevents the silent bugs that happen when the property exists but has the wrong type. The type system forces validation at the point of use.
Practical Comparison: When to Reach for Each Type in Real APIs
Choosing between object, {}, and Record<string, unknown> depends on what the function actually needs to do with the value. Each type signals a different contract to callers and enforces different constraints at compile time.
%% alt: Decision flowchart comparing when to use each object type
flowchart LR
A("Function receives a value")
subgraph B["Use object"]
B1("Excludes primitives and null")
B2("Accepts arrays, functions, instances")
B3("Cache keys, WeakMap, identity checks")
end
subgraph C["Use Record string unknown"]
C1("Enforces plain object dictionary")
C2("String keys, unknown values")
C3("Config merge, header parsing, env vars")
end
subgraph D["Never use empty braces"]
D1("Accepts primitives and objects")
D2("Too permissive for parameters")
D3("Use unknown for top type instead")
end
A --> B
A --> C
A --> D
style B3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D2 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Use object when the function needs "not a primitive or null" but does not care about object shape. WeakMap keys, cache storage, reference comparison, and serialization all fit this pattern. The value's identity matters, not its properties.
// Correct use of object: reference comparison only
function trackVisited(item: object, visited: Set<object>): boolean {
if (visited.has(item)) {
return true;
}
visited.add(item);
return false;
}
// The function works with any non-primitive reference
trackVisited({ id: 1 }, new Set()); // Plain object
trackVisited([1, 2, 3], new Set()); // Array
trackVisited(() => {}, new Set()); // FunctionUse Record<string, unknown> when the function iterates over keys, merges properties, or validates dictionary structure. Configuration objects, HTTP headers, query parameters, and environment variables all follow this pattern. The value must be a plain object with string keys.
// Correct use of Record: configuration merge
function mergeConfig(
base: Record<string, unknown>,
overrides: Record<string, unknown>
): Record<string, unknown> {
const merged: Record<string, unknown> = {};
for (const key of Object.keys(base)) {
merged[key] = base[key];
}
for (const key of Object.keys(overrides)) {
merged[key] = overrides[key];
}
return merged;
}
// Type checker prevents arrays and functions
mergeConfig({ timeout: 5000 }, { retry: 3 }); // Works
mergeConfig({ timeout: 5000 }, [1, 2, 3]); // Type errorNever use {} as a function parameter type. It accepts primitives, which breaks the mental model of "object type". When the function truly accepts any value including primitives, use unknown instead. The unknown type forces narrowing before use and signals the right intent.
// Wrong: {} accepts primitives but suggests objects
function logValue(value: {}) {
console.log(JSON.stringify(value));
}
// Right: unknown accepts anything and signals need for narrowing
function logValue(value: unknown) {
if (value === null || value === undefined) {
console.log("null");
return;
}
console.log(JSON.stringify(value));
}The pattern that emerges is: stronger types at boundaries, weaker types internally. API entry points should use Record<string, unknown> or specific interfaces to constrain what callers can pass. Internal functions that manipulate already-validated data can use object or even any where appropriate.
// Strong type at boundary
function handleRequest(
headers: Record<string, string>,
body: Record<string, unknown>
) {
// Validate and narrow body properties
if (typeof body.action !== "string") {
throw new Error("Missing action");
}
// Pass to internal function with weaker type
return processAction(body.action, body);
}
// Weaker type internally after validation
function processAction(action: string, data: object) {
// Already validated, can safely cast if needed
}This approach pushes type checking to the edges of the system. Once data passes the boundary validation, internal code operates on known shapes without constant narrowing. The compile-time safety happens where it matters most.
Real-World Patterns: Fixing Type Bugs by Choosing the Right Object Type
Most production bugs from wrong object types follow a pattern: the function declares a parameter as one type, but the runtime receives a value that technically satisfies the type constraint while breaking the function's assumptions. The fix requires matching the type to what the function actually does.
%% alt: Flowchart showing how to fix object type bugs by matching type to function behavior
flowchart LR
A("Function fails with unexpected argument")
B("Check what function does with parameter")
C("Accesses properties by name?")
D("Iterates over keys?")
E("Only checks reference equality?")
F("Change type to Record string unknown")
G("Keep type as object")
A --> B
B --> C
C -->|Yes| D
D -->|Yes| F
C -->|No| E
E -->|Yes| G
style A stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style F stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Pattern one: function uses object but needs dictionary operations. The caller passes an array or function, which satisfies object. The runtime tries to read properties and gets undefined or crashes on methods that do not exist.
// Bug: object type allows arrays
function extractMetadata(data: object): Record<string, string> {
const result: Record<string, string> = {};
// Fails if data is an array: Object.keys returns numeric indices
for (const key of Object.keys(data)) {
result[key] = String((data as any)[key]);
}
return result;
}
// Fix: use Record to enforce dictionary structure
function extractMetadata(
data: Record<string, unknown>
): Record<string, string> {
const result: Record<string, string> = {};
for (const key of Object.keys(data)) {
const value = data[key];
result[key] = typeof value === "string" ? value : String(value);
}
return result;
}Pattern two: function uses {} expecting objects, but receives primitives. The caller passes a number or string from an upstream API. The function assumes property access will work. The runtime silently does nothing or throws when trying to modify properties.
// Bug: {} accepts primitives
function addTimestamp(data: {}) {
// Appears to work, but creates wrapper object on primitives
(data as any).timestamp = Date.now();
return data;
}
const payload = 42;
const result = addTimestamp(payload);
// result is still 42, timestamp was added to temporary wrapper
// Fix: use Record to enforce mutable object
function addTimestamp(data: Record<string, unknown>): Record<string, unknown> {
data.timestamp = Date.now();
return data;
}Pattern three: function uses Record<string, unknown> but only needs reference identity. The type is too strict. The function cannot accept arrays or class instances that happen to pass through unchanged. The code adds unnecessary casting or widens the type to any.
// Bug: Record rejects valid use cases
function memoize<T extends Record<string, unknown>, R>(
fn: (arg: T) => R
): (arg: T) => R {
const cache = new Map<T, R>();
return (arg: T) => {
if (cache.has(arg)) {
return cache.get(arg)!;
}
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
// Cannot memoize functions that take arrays or class instances
const memoized = memoize((arr: number[]) => arr.reduce((a, b) => a + b, 0));
// Fix: use object for cache key
function memoize<T extends object, R>(
fn: (arg: T) => R
): (arg: T) => R {
const cache = new Map<T, R>();
return (arg: T) => {
if (cache.has(arg)) {
return cache.get(arg)!;
}
const result = fn(arg);
cache.set(arg, result);
return result;
};
}The diagnostic question is: what does this function do with the parameter? If it accesses properties, needs Record<string, unknown>. If it only stores references or checks identity, needs object. If it uses the value opaquely and might receive primitives, needs unknown. Never default to {}.
Real-world codebases accumulate {} and object in function signatures because developers copy patterns without understanding the implications. The fix is not a codebase-wide search and replace. The fix is auditing each function to match the type to the actual operations.
// Audit pattern: look at what the function does
function processResponse(response: object) { // Current type
// Function accesses properties → needs Record
const status = (response as any).status;
const body = (response as any).body;
// Function calls Object.keys → needs Record
for (const key of Object.keys(response)) {
// ...
}
}
// After audit: correct type based on operations
function processResponse(response: Record<string, unknown>) {
// Now type-safe with narrowing
const status = response.status;
if (typeof status !== "number") {
throw new Error("Invalid status");
}
const body = response.body;
if (typeof body !== "string") {
throw new Error("Invalid body");
}
}The pattern that prevents these bugs is: declare types based on what the function does, not based on what the caller might pass. The type system exists to enforce contracts. The contract should match the implementation.
Frequently Asked Questions
When should I use object instead of Record<string, unknown>?
Use object when the function only needs to know the value is not a primitive or null, and does not care about object shape. WeakMap keys, Set members, reference comparison, and identity checks all work with object. Use Record<string, unknown> when the function accesses properties by name, iterates over keys, or merges dictionary data.
Why does {} accept primitives if it looks like an empty object?
TypeScript uses structural typing. The {} type means "has zero required properties". All values except null and undefined have at least zero properties (primitives have prototype methods), so they all satisfy {}. This is confusing but follows from the type system rules. Avoid {} as a parameter type. Use unknown when you need to accept any value.
How do I access properties on a Record<string, unknown> without type errors?
Every property access on Record<string, unknown> returns unknown. Narrow the type before use with typeof checks, instanceof checks, or custom type guards. This is intentional. The type system forces you to validate that the property exists and has the expected type before using it.
Can I use object for JSON API responses?
No. JSON responses are plain objects with string keys, which means Record<string, unknown> is the right type before validation. After validation, define a specific interface for the response shape. Using object allows arrays and functions to pass type checking, which causes runtime errors when the code tries to access response properties.
What is the difference between object and Object with a capital O?
The object type (lowercase) excludes primitives and represents non-primitive values. The Object type (uppercase) is the prototype object and should almost never be used as a type annotation. Always use lowercase object, Record<string, unknown>, or a specific interface. The Object type accepts primitives due to autoboxing and provides no useful type safety.
Conclusion: Matching Your Intent to TypeScript's Object Type System
The gap between object, {}, and Record<string, unknown> is not subtle terminology. Each type accepts a different set of values. Developers who choose based on intuition write code that compiles but fails in production. The fix is matching the type to what the function actually does with the parameter.
Use object when the function needs reference identity: cache keys, WeakMaps, visited tracking. Use Record<string, unknown> when the function operates on dictionaries: configuration merge, header parsing, property iteration. Never use {} as a parameter type. When the function accepts any value including primitives, use unknown and narrow at the point of use.
That covers the essential patterns for TypeScript object types. Apply these in production and the difference will be immediate: fewer runtime crashes, clearer function contracts, and type errors that catch bugs at compile time instead of in production logs.