Skip to main content

How to Use the TypeScript keyof Operator

You've built a function to update user settings, and it compiles fine. But when you pass "theme" as a property name, TypeScript doesn't catch that it should be "themeName". Or worse, you're iterating over API response keys with Object.keys(), and TypeScript treats them as generic strings instead of the actual property names you know exist. This is where keyof becomes essential. It gives you compile-time safety when working with object keys, catching typos and invalid property access before your code ever runs.

Accessing Object Key Names with keyof

The keyof operator creates a union type of all the keys of a given object type. Here's how it works in practice:

interface UserSettings {
username: string;
email: string;
notificationsEnabled: boolean;
}

type SettingKey = keyof UserSettings;
// SettingKey is "username" | "email" | "notificationsEnabled"

// Now you can restrict function parameters to valid keys only
function getSetting(key: SettingKey, settings: UserSettings) {
return settings[key]; // Type-safe access
}

const settings: UserSettings = {
username: "alice_dev",
email: "alice@example.com",
notificationsEnabled: true
};

getSetting("email", settings); // OK
// getSetting("theme", settings); // Error: "theme" is not a valid key

This catches invalid property access at compile time instead of runtime. When building data validation models in Convex, this type-safety becomes invaluable for maintaining schema consistency.

The typeof operator is commonly used alongside keyof to extract keys from object instances, giving you access to their property names as types. This technique is essential when working with typescript utility types for building flexible, type-safe functions.

keyof vs Object.keys(): Understanding the Difference

A common confusion is the difference between TypeScript's keyof operator and JavaScript's Object.keys() method. They look similar but work at completely different times.

interface ApiResponse {
userId: string;
status: number;
data: unknown;
}

// keyof works at COMPILE TIME - it's a type operator
type ResponseKeys = keyof ApiResponse;
// Result: "userId" | "status" | "data"

// Object.keys() works at RUNTIME - it's a JavaScript function
const response: ApiResponse = {
userId: "123",
status: 200,
data: { name: "Alice" }
};

const keys = Object.keys(response);
// Type: string[] (not "userId" | "status" | "data")

Why does Object.keys() return string[] instead of the specific keys? Because JavaScript is dynamic. At runtime, objects can have extra properties added:

const response: ApiResponse = {
userId: "123",
status: 200,
data: { name: "Alice" }
};

// JavaScript allows this at runtime
(response as any).extraProperty = "unexpected";

// Object.keys() sees ALL properties, including ones added at runtime
console.log(Object.keys(response));
// ["userId", "status", "data", "extraProperty"]

TypeScript can't guarantee what properties will exist at runtime, so it safely types Object.keys() as string[]. If you're certain about your object's structure, you can create a helper:

function getTypedKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}

// Now you get typed keys
const typedKeys = getTypedKeys(response);
// Type: ("userId" | "status" | "data")[]

Just remember: this removes TypeScript's safety net. Use it only when you control the object's structure completely.

Creating a TypeScript Type for Dynamic Property Access

To create a TypeScript type using keyof for dynamic property access, combine it with generics:

interface ApiConfig {
baseUrl: string;
timeout: number;
retryAttempts: number;
}

function getConfigValue<T, K extends keyof T>(config: T, key: K): T[K] {
return config[key];
}

const apiConfig: ApiConfig = {
baseUrl: "https://api.example.com",
timeout: 5000,
retryAttempts: 3
};

// TypeScript knows the return type of each call
const url = getConfigValue(apiConfig, "baseUrl"); // Type: string
const timeout = getConfigValue(apiConfig, "timeout"); // Type: number

// This causes a compile error
// getConfigValue(apiConfig, "invalidKey"); // Error!

The function returns different types based on which key you access. When using "baseUrl", you get a string. With "timeout", you get a number. This pattern is frequently used in Convex filter helpers to ensure type safety when creating database queries.

The typescript generics combined with keyof create a powerful type constraint that ensures you can only access properties that exist on the object.

Restricting Function Parameters to Object Keys

You can use keyof to build functions that accept only valid property names and enforce that values match the expected type:

function updateConfig<T, K extends keyof T>(
config: T,
key: K,
value: T[K]
): T {
return { ...config, [key]: value };
}

const apiConfig = {
baseUrl: "https://api.example.com",
timeout: 5000,
retryAttempts: 3
};

// Both key and value types are checked
updateConfig(apiConfig, "baseUrl", "https://api.new.com"); // OK
updateConfig(apiConfig, "timeout", 3000); // OK

// These cause type errors:
// updateConfig(apiConfig, "baseUrl", 42); // Error: number not assignable to string
// updateConfig(apiConfig, "maxConnections", 10); // Error: "maxConnections" isn't a valid key

This pattern is particularly useful when implementing validation helpers or building form libraries. The value parameter has the type T[K], which means "the type of property K in object T." This indexed access type ensures that when updating "baseUrl", you must provide a string, and when updating "timeout", you must provide a number.

The typescript function types here includes a return type that preserves the original object's shape, making this pattern safe for immutable updates and state management.

Using keyof with Index Signatures

When your type includes an index signature, keyof behaves differently than you might expect:

interface StringMap {
[key: string]: boolean;
}

type StringMapKeys = keyof StringMap;
// Result: string | number (not just string!)

Why string | number? Because in JavaScript, numeric keys are automatically converted to strings. These are equivalent at runtime:

const obj: StringMap = {};
obj[0] = true; // Numeric key
obj["0"] = true; // String key - same property!

This becomes important when working with dynamic data structures:

interface CacheWithIndex {
[id: string]: { data: unknown; timestamp: number };
// But we also have some known properties
maxSize: number;
ttl: number;
}

type CacheKeys = keyof CacheWithIndex;
// Result: string | number | "maxSize" | "ttl"

function clearCacheEntry<K extends keyof CacheWithIndex>(
cache: CacheWithIndex,
key: K
): void {
if (key !== "maxSize" && key !== "ttl") {
delete cache[key];
}
}

If you only want the string literal keys (not the index signature), you can filter them:

type LiteralKeysOnly<T> = keyof T extends string | number
? Extract<keyof T, string> extends infer K
? K extends string
? string extends K
? never
: K
: never
: never
: keyof T;

type ConfigKeys = LiteralKeysOnly<CacheWithIndex>;
// Result: "maxSize" | "ttl"

Template Literal Types with keyof

TypeScript 4.1+ lets you combine template literal types with keyof to generate new property names dynamically. This is powerful for creating type-safe getter methods, event handlers, or prefixed properties:

interface User {
name: string;
email: string;
age: number;
}

// Generate getter method names: "getName", "getEmail", "getAge"
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// Result:
// {
// getName: () => string;
// getEmail: () => string;
// getAge: () => number;
// }

Notice the string & K pattern. Since keyof can include symbols, we intersect with string to ensure we only work with string keys (template literals only work with strings).

Here's a practical example for event handlers:

interface FormState {
username: string;
password: string;
rememberMe: boolean;
}

// Generate "onUsernameChange", "onPasswordChange", etc.
type FormHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (value: T[K]) => void;
};

type FormEventHandlers = FormHandlers<FormState>;
// Result:
// {
// onUsernameChange: (value: string) => void;
// onPasswordChange: (value: string) => void;
// onRememberMeChange: (value: boolean) => void;
// }

// Implement the handlers with full type safety
const handlers: FormEventHandlers = {
onUsernameChange: (value) => console.log("Username:", value), // value is string
onPasswordChange: (value) => console.log("Password:", value), // value is string
onRememberMeChange: (value) => console.log("Remember:", value), // value is boolean
};

You can also filter which properties get transformed:

// Only create handlers for string properties
type StringPropertyHandlers<T> = {
[K in keyof T as T[K] extends string ? `update${Capitalize<string & K>}` : never]: (value: string) => void;
};

type UserStringHandlers = StringPropertyHandlers<User>;
// Result:
// {
// updateName: (value: string) => void;
// updateEmail: (value: string) => void;
// // age is excluded because it's a number
// }

Combining keyof with Interfaces for Type Safety

Combining keyof with interfaces improves type safety, especially with complex objects or when extending interfaces:

interface DatabaseRecord {
id: string;
createdAt: Date;
updatedAt: Date;
}

interface Product extends DatabaseRecord {
name: string;
price: number;
inStock: boolean;
}

function getField<T, K extends keyof T>(record: T, field: K): T[K] {
return record[field];
}

const product: Product = {
id: "prod_123",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-06-15"),
name: "Laptop",
price: 999,
inStock: true
};

const productName = getField(product, "name"); // Type: string
const createdDate = getField(product, "createdAt"); // Type: Date
const inStock = getField(product, "inStock"); // Type: boolean

This approach is particularly useful when working with complex data relationships in your database models. By combining interfaces with keyof, you create self-documenting code that's resistant to errors, especially when your interfaces evolve over time.

If you're using a database or API layer like Convex, this pattern works well with typed schemas to ensure your application code correctly handles all the fields defined in your database.

The typescript interface system combined with keyof creates a robust foundation for building maintainable applications. This pattern also works well with typescript record types when you need to enforce consistency across similar objects.

Implementing a Utility Type with keyof for Object Key Mapping

The keyof operator works well when creating utility types that transform object types. One common pattern is mapped types that change the type of each property while preserving the keys:

type Nullable<T> = {
[K in keyof T]: T[K] | null;
};

interface ApiResponse {
userId: string;
profileImage: string;
lastLogin: Date;
}

// All properties can now be null (useful for partial API responses)
type PartialApiResponse = Nullable<ApiResponse>;
// Result: {
// userId: string | null;
// profileImage: string | null;
// lastLogin: Date | null;
// }

You can create more sophisticated transformations:

// Convert all properties to loading states
type LoadingState<T> = {
[K in keyof T]: {
loading: boolean;
data: T[K] | null;
error: Error | null;
};
};

interface UserProfile {
name: string;
followers: number;
}

type UserProfileState = LoadingState<UserProfile>;
// Result: {
// name: { loading: boolean; data: string | null; error: Error | null };
// followers: { loading: boolean; data: number | null; error: Error | null };
// }

This pattern is frequently used when building validation systems, form state tracking, or creating metadata for existing types. For example, you might use this approach when implementing complex filters or working with schema validation in Convex.

The [K in keyof T] syntax iterates over each key in type T, creating a new property with the same name but a different type. This technique integrates well with other typescript utility types like Partial<T>, Pick<T, K>, or Omit<T, K>.

Creating a Union of Object Property Names with keyof

One of the simplest yet most useful applications of keyof is creating a union type of all property names in an object type:

interface ProductFilters {
category: string;
minPrice: number;
maxPrice: number;
inStock: boolean;
}

type FilterKey = keyof ProductFilters;
// Type: "category" | "minPrice" | "maxPrice" | "inStock"

This union type can then restrict function parameters, create mapped types, or serve as the basis for more complex type operations. When working with a Convex database schema, this pattern is particularly useful for creating type-safe query builders.

Here's a practical sorting example:

interface Product {
name: string;
price: number;
rating: number;
createdAt: Date;
}

function sortProducts<K extends keyof Product>(
products: Product[],
sortKey: K
): Product[] {
return [...products].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];

if (aVal < bVal) return -1;
if (aVal > bVal) return 1;
return 0;
});
}

// TypeScript ensures you can only sort by valid keys
const byPrice = sortProducts(products, "price"); // OK
const byRating = sortProducts(products, "rating"); // OK
// const byColor = sortProducts(products, "color"); // Error: "color" doesn't exist

This approach works especially well with typescript map type patterns when you need to transform property names while maintaining type safety.

Using keyof with Generics for Flexible Function Typing

Using keyof with generics allows for flexible function typing that works across different object types:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

interface ShoppingCart {
items: Array<{ id: string; quantity: number }>;
total: number;
currency: string;
}

interface ShippingInfo {
address: string;
method: string;
estimatedDays: number;
}

const cart: ShoppingCart = {
items: [{ id: "item1", quantity: 2 }],
total: 49.99,
currency: "USD"
};

const shipping: ShippingInfo = {
address: "123 Main St",
method: "express",
estimatedDays: 2
};

// Same function works with different object types
const cartTotal = getProperty(cart, "total"); // Type: number
const shippingMethod = getProperty(shipping, "method"); // Type: string

This keeps function typing flexible while maintaining type safety. It's especially useful when working with Convex's typed API generation, allowing you to build generic helpers that work across your entire data model.

The real power comes from how TypeScript automatically infers both the object type and the key type based on the arguments you pass. This makes the function both flexible and type-safe without requiring explicit type annotations at the call site.

You can extend this pattern to create more sophisticated utility functions, as shown in Convex's functional relationship helpers:

function updateProperty<T, K extends keyof T>(
obj: T,
key: K,
value: T[K]
): T {
return { ...obj, [key]: value };
}

// Type safety preserved for both keys and values
const updatedCart = updateProperty(cart, "total", 59.99); // OK
const updatedShipping = updateProperty(shipping, "estimatedDays", 3); // OK

// These fail at compile time:
// updateProperty(cart, "total", "invalid"); // Error: string not assignable to number
// updateProperty(cart, "invalidKey", 123); // Error: "invalidKey" doesn't exist

This approach combines typescript generics with typescript function return type to create adaptable, type-safe utility functions.

Common Pitfalls and Misunderstandings with keyof

Forgetting typeof for object instances

A common mistake is expecting keyof to extract keys from instance objects directly:

const apiEndpoints = {
users: "/api/users",
products: "/api/products",
orders: "/api/orders"
};

// This doesn't work - keyof expects a type, not a value
// type EndpointKeys = keyof apiEndpoints; // Error!

// Correct approach using typeof
type EndpointKeys = keyof typeof apiEndpoints; // "users" | "products" | "orders"

This is a frequent issue when starting with TypeScript, especially when transitioning from working with complex schemas in Convex. The typeof operator extracts the type from a value, then keyof extracts the keys from that type.

Using keyof with primitive types

Another mistake is using keyof with primitive types, which produces unexpected results:

// Not what you'd expect
type StringKeys = keyof string;
// Result: "toString" | "charAt" | "charCodeAt" | "concat" | ... (all string methods)

type NumberKeys = keyof number;
// Result: "toString" | "toFixed" | "toPrecision" | ... (all number methods)

keyof on primitives returns the keys of their wrapper objects, which includes all methods and properties from their prototype. When building type-safe backends with Convex, be aware of this behavior when working with scalar fields.

Not considering optional properties

With optional properties, keyof includes them in the union, but you might need additional checks when accessing them:

interface ApiUser {
id: string;
username: string;
email?: string; // Optional property
phoneNumber?: string; // Optional property
}

type UserKeys = keyof ApiUser;
// Result: "id" | "username" | "email" | "phoneNumber"

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // May return undefined for optional properties
}

const user: ApiUser = { id: "123", username: "alice" };
const email = getValue(user, "email"); // Type: string | undefined

typescript optional parameters in interfaces require careful handling when used with keyof. Similarly, typescript union types might need additional narrowing when working with keyof.

Assuming keyof preserves property order

Don't rely on any specific order for keys produced by keyof:

interface Config {
timeout: number;
retries: number;
baseUrl: string;
}

type ConfigKeys = keyof Config;
// The order of "timeout" | "retries" | "baseUrl" is not guaranteed

If you need ordered keys, maintain a separate array:

const CONFIG_KEYS = ["timeout", "retries", "baseUrl"] as const;
type ConfigKey = typeof CONFIG_KEYS[number]; // "timeout" | "retries" | "baseUrl"

// Now you have both order and type safety
CONFIG_KEYS.forEach(key => {
// Process in guaranteed order
});

Final Thoughts on TypeScript keyof

The keyof operator helps you access and manipulate object properties with type safety. By extracting property names as types, it catches errors during compilation rather than at runtime.

Key takeaways:

  • Use keyof to create union types of object property names
  • Combine with generics for type-safe functions that work across different object types
  • Apply it in mapped types to transform object properties
  • Remember to use typeof when working with object instances
  • Understand the difference between keyof (compile-time) and Object.keys() (runtime)
  • Leverage template literal types with keyof for dynamic property name generation
  • Watch out for index signatures, which cause keyof to return broader types

When building applications with Convex, keyof helps maintain type safety across your database schema, API calls, and UI components. By using this operator, you'll write more reliable code with fewer bugs and better developer experience.