Skip to main content

Advanced type operations

In this chapter, you learn about TypeScript's advanced type operation features. Combining them lets you write more type-safe and expressive code.

What you learn in this chapter

  • The keyof operator
  • Lookup types (Indexed Access Types)
  • Index signatures
  • Optional Chaining (?.)
  • Nullish Coalescing (??)
  • const assertions
info

In this chapter, you learn how to operate on object properties at the type level. Combined with generics, these become highly expressive features.

The keyof operator

The keyof operator gets all of an object type's property names as a union type.

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

// keyof gets a union type of the property names
type UserKeys = keyof User;
// "id" | "name" | "email" | "age"

// A function that accesses a property type-safely
function getUserProperty(user: User, key: UserKeys): string | number {
return user[key];
}

const user: User = {
id: 1,
name: "Yamada Taro",
email: "yamada@example.com",
age: 30
};

console.log(getUserProperty(user, "name")); // "Yamada Taro"
console.log(getUserProperty(user, "age")); // 30
// getUserProperty(user, "invalid"); // Error: a key that does not exist

A use of keyof

// A function that gets an object's keys
function getKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}

const product = {
name: "Laptop",
price: 100000,
stock: 50
};

// keys is of type ("name" | "price" | "stock")[]
const keys = getKeys(product);
console.log(keys); // ["name", "price", "stock"]

// Enumerate the properties type-safely
keys.forEach(key => {
console.log(`${key}: ${product[key]}`);
});

Lookup types (Indexed Access Types)

A lookup type gets the type of a specific property from an object type.

interface User {
id: number;
name: string;
email: string;
profile: {
age: number;
address: string;
};
}

// Get the type of a specific property
type UserId = User["id"]; // number
type UserName = User["name"]; // string
type UserProfile = User["profile"]; // { age: number; address: string; }

// You can also get the type of a nested property
type UserAge = User["profile"]["age"]; // number

// A union of multiple property types
type UserIdOrName = User["id" | "name"]; // number | string

// Combine it with keyof
type UserPropertyTypes = User[keyof User];
// number | string | { age: number; address: string; }

A use in a generic function

// A fully type-safe property access function
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

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

const product: Product = {
id: 1,
name: "Laptop",
price: 100000
};

// The return type is inferred precisely
const productName = getProperty(product, "name"); // string type
const productPrice = getProperty(product, "price"); // number type

// Specifying a key that does not exist is an error
// getProperty(product, "invalid"); // Error

Getting the element type of an array

// Get the element type from an array type
type ArrayElement<T extends readonly any[]> = T[number];

type StringArray = string[];
type StringElement = ArrayElement<StringArray>; // string

// It can also be used with tuple types
type Tuple = [string, number, boolean];
type TupleElements = Tuple[number]; // string | number | boolean

// A practical example: generate a type from a constant array
const COLORS = ["red", "green", "blue"] as const;
type Color = typeof COLORS[number]; // "red" | "green" | "blue"

Index signatures

You can define the type of an object with dynamic property names.

A basic index signature

// An object that allows arbitrary property names
interface StringMap {
[key: string]: string;
}

const userNames: StringMap = {
user1: "Yamada Taro",
user2: "Tanaka Hanako",
user3: "Sato Jiro"
// arbitrary properties can be added
};

userNames.user4 = "Suzuki Saburo"; // OK
userNames.anyKey = "Any value"; // OK

Combining with fixed properties

interface UserData {
id: number; // required property
name: string; // required property
[key: string]: string | number; // other arbitrary properties
}

const user: UserData = {
id: 1,
name: "Yamada Taro",
age: 30, // OK: number type
email: "yamada@example.com", // OK: string type
address: "Tokyo" // OK: string type
};

// Note: the type of a fixed property must be compatible with the index signature's type

A number index

// An array-like object
interface NumberDictionary {
[index: number]: string;
}

const fruits: NumberDictionary = {
0: "Apple",
1: "Banana",
2: "Orange"
};

console.log(fruits[0]); // "Apple"
console.log(fruits[1]); // "Banana"

The Record utility type

You can use the Record type as an alternative to an index signature.

// An alternative to an index signature
type UserRoles = Record<string, string>;

const roles: UserRoles = {
admin: "Admin",
editor: "Editor",
viewer: "Viewer"
};

// A more type-safe approach: use a union type as the key
type RoleName = "admin" | "editor" | "viewer";
type UserRolesStrict = Record<RoleName, string>;

const strictRoles: UserRolesStrict = {
admin: "Admin",
editor: "Editor",
viewer: "Viewer"
// invalidRole: "Invalid" // Error: a key that is not allowed
};

Optional Chaining (?.)

An operator for safely accessing nested properties.

// Defining the nested object types
interface Address {
street: string;
city: string;
}

interface UserProfile {
name: string;
address?: Address; // optional
}

interface ApiResponse {
user?: UserProfile; // optional
}

// Without Optional Chaining
function getCityOld(response: ApiResponse): string | undefined {
// multiple if statements are needed
if (response.user && response.user.address) {
return response.user.address.city;
}
return undefined;
}

// With Optional Chaining
function getCity(response: ApiResponse): string | undefined {
// access safely with ?.
// if anything along the way is null/undefined, return undefined
return response.user?.address?.city;
}

const response1: ApiResponse = {
user: {
name: "Yamada Taro",
address: {
street: "1-2-3",
city: "Tokyo"
}
}
};

const response2: ApiResponse = {
user: {
name: "Tanaka Hanako"
// no address
}
};

const response3: ApiResponse = {};

console.log(getCity(response1)); // "Tokyo"
console.log(getCity(response2)); // undefined
console.log(getCity(response3)); // undefined

Applying it to arrays and methods

interface UserData {
friends?: string[];
greet?: () => void;
}

const user1: UserData = {
friends: ["Alice", "Bob"],
greet() { console.log("Hello!"); }
};

const user2: UserData = {};

// Optional Chaining on an array
console.log(user1.friends?.[0]); // "Alice"
console.log(user2.friends?.[0]); // undefined

// Optional Chaining on a method
user1.greet?.(); // "Hello!"
user2.greet?.(); // nothing happens (no error)

Nullish Coalescing (??)

An operator that sets a default value when a value is null or undefined.

// The Nullish Coalescing operator
function getDisplayName(name: string | null | undefined): string {
// if name is null or undefined, return "Guest"
return name ?? "Guest";
}

console.log(getDisplayName("Yamada Taro")); // "Yamada Taro"
console.log(getDisplayName(null)); // "Guest"
console.log(getDisplayName(undefined)); // "Guest"

The difference from the || operator

// Be careful about the difference from the || operator
function getCount(count: number | null): number {
// Using the || operator
const result1 = count || 10;
// 0 is also falsy, so even count=0 returns 10

// Using the ?? operator
const result2 = count ?? 10;
// 0 is neither null nor undefined, so 0 is returned

return result2;
}

console.log(getCount(0)); // 0 (with ||, it would be 10)
console.log(getCount(null)); // 10
OperatorBehavior with falsy valuesUse
||Returns the right side for all falsy valuesDefault values (when you want to exclude 0, "")
??Returns the right side only for null/undefinedWhen you want to replace only null/undefined

A practical example: optional parameters

interface SearchOptions {
query: string;
limit?: number;
offset?: number;
}

function search(options: SearchOptions): void {
// Set default values with ??
const limit = options.limit ?? 20; // default 20
const offset = options.offset ?? 0; // default 0

console.log(`Search: ${options.query}`);
console.log(`Display count: ${limit}`);
console.log(`Offset: ${offset}`);
}

search({ query: "TypeScript" });
// Search: TypeScript
// Display count: 20
// Offset: 0

search({ query: "JavaScript", limit: 10, offset: 5 });
// Search: JavaScript
// Display count: 10
// Offset: 5

search({ query: "Node.js", limit: 0 }); // limit=0 is also valid
// Search: Node.js
// Display count: 0
// Offset: 0

const assertions

Using as const makes a value a literal type and readonly (read-only).

A basic const assertion

// A normal literal
let name1 = "Taro"; // string type
const name2 = "Taro"; // "Taro" type (literal type)

// Using a const assertion
let name3 = "Taro" as const; // "Taro" type (literal type)

// A const assertion on an array
const colors = ["red", "green", "blue"] as const;
// readonly ["red", "green", "blue"]

// colors[0] = "yellow"; // Error: read-only
// colors.push("yellow"); // Error: read-only

// A const assertion on an object
const user = {
name: "Yamada Taro",
age: 30
} as const;

// user.age = 31; // Error: read-only

The effects of a const assertion

EffectDescription
Literal typeA primitive value becomes a literal type
readonlyAll properties become read-only
Tuple-ificationAn array becomes a read-only tuple

A practical example: a configuration object

// Make a configuration object immutable
const CONFIG = {
apiUrl: "https://api.example.com",
timeout: 5000,
retryCount: 3,
features: ["feature1", "feature2"]
} as const;

// Get the CONFIG type
type Config = typeof CONFIG;
// {
// readonly apiUrl: "https://api.example.com";
// readonly timeout: 5000;
// readonly retryCount: 3;
// readonly features: readonly ["feature1", "feature2"];
// }

// Values cannot be changed
// CONFIG.timeout = 10000; // Error: read-only

Generating a union type from a constant

// A constant array
const STATUS_LIST = ["pending", "active", "completed"] as const;

// Generate a union type from the array's elements
type Status = typeof STATUS_LIST[number];
// "pending" | "active" | "completed"

// Use this type
function setStatus(status: Status): void {
console.log(`Changed status to ${status}`);
}

setStatus("pending"); // OK
setStatus("active"); // OK
// setStatus("invalid"); // Error

Generating a type from an object

const ROLE_MAP = {
admin: "Admin",
editor: "Editor",
viewer: "Viewer"
} as const;

// A union type of the keys
type RoleKey = keyof typeof ROLE_MAP;
// "admin" | "editor" | "viewer"

// A union type of the values
type RoleValue = typeof ROLE_MAP[RoleKey];
// "Admin" | "Editor" | "Viewer"

The satisfies operator

Introduced in TypeScript 4.9, the satisfies operator lets you type-check while preserving literal types.

Basic usage

// Define the config type
type Config = {
theme: "light" | "dark";
fontSize: number;
features: string[];
};

// ❌ Using a type annotation: the literal types are lost
const config1: Config = {
theme: "dark",
fontSize: 14,
features: ["search", "export"]
};
// the type of config1.theme is "light" | "dark" (not "dark")

// ✅ Using satisfies: type-check while preserving the literal types
const config2 = {
theme: "dark",
fontSize: 14,
features: ["search", "export"]
} satisfies Config;
// the type of config2.theme is "dark" (preserves the literal type)
// the type of config2.features is string[]

// Combine it with as const for fully literal types
const config3 = {
theme: "dark",
fontSize: 14,
features: ["search", "export"]
} as const satisfies Config;
// the type of config3.features is readonly ["search", "export"]

The difference from a type annotation

type Color = "red" | "green" | "blue";
type ColorMap = Record<Color, string>;

// Type annotation: the value type is string
const colors1: ColorMap = {
red: "#ff0000",
green: "#00ff00",
blue: "#0000ff"
};
console.log(colors1.red); // type: string

// satisfies: preserve the value's literal type
const colors2 = {
red: "#ff0000",
green: "#00ff00",
blue: "#0000ff"
} satisfies ColorMap;
console.log(colors2.red); // type: "#ff0000" (literal type)

A practical example: route definitions

// Defining the route type
type Route = {
path: string;
component: string;
requiresAuth?: boolean;
};

type Routes = Record<string, Route>;

// satisfies type-checks while preserving the literal types
const routes = {
home: { path: "/", component: "Home" },
dashboard: { path: "/dashboard", component: "Dashboard", requiresAuth: true },
profile: { path: "/profile", component: "Profile", requiresAuth: true }
} satisfies Routes;

// You can get the type of the keys
type RouteKey = keyof typeof routes; // "home" | "dashboard" | "profile"

// The literal types of each property are preserved too
type HomePath = typeof routes.home.path; // "/"

When to use satisfies

Use the following table as a guide for choosing between satisfies and a type annotation.

SituationRecommendationReason
You want to use a value as its concrete type (literal types matter)satisfiesType-check while preserving literal types
Defining a contract type such as an APIType annotation (:)Make the intent of the definition clear
Immutable config values / master dataas const satisfiesFix the value + enforce the type constraint at the same time
A function's arguments / return valueType annotation (:)Make the function signature strict
Satisfy a partial type while preserving extra informationsatisfiesA type annotation would drop the information

Template Literal Types (TS 4.1+)

Template Literal Types are a mechanism for combining string literal types into new string literal types. Think of it as reproducing JavaScript's template literal syntax at the type level.

The syntax of template literal types

type Hello = `Hello, ${string}`
// Hello is any string type that starts with "Hello, "

const greeting1: Hello = 'Hello, TypeScript' // OK
const greeting2: Hello = 'Hi there' // Error

Combining with union types

When combined with union types, Template Literal Types expand into all combinations.

type Color = 'red' | 'blue'
type Size = 'small' | 'large'

type Variant = `${Color}-${Size}`
// 'red-small' | 'red-large' | 'blue-small' | 'blue-large'

A practical example: type-safe generation of event names

type EventName<T extends string> = `on${Capitalize<T>}`

type Events = 'click' | 'hover' | 'focus'
type HandlerProps = {
[K in Events as EventName<K>]: (e: Event) => void
}
// {
// onClick: (e: Event) => void;
// onHover: (e: Event) => void;
// onFocus: (e: Event) => void;
// }

Capitalize<T> used here is one of the string manipulation types covered in Chapter 15, and it capitalizes the first letter. Its strength is that, combined with Template Literal Types, you can build a type-safe DSL (domain-specific language).

A detailed combination of Template Literal Types and infer is covered in Chapter 16 (Mapped Types and Conditional Types).

Try it: build type-safe object operation functions ★★★

Implement general-purpose object operation functions that meet the following requirements.

Requirements:

  1. A getProperty function: get the value of the given key from an object
  2. An updateProperty function: update the given property of an object
  3. Both functions are type-safe, and specifying a key that does not exist is a compile error
interface User {
id: number;
name: string;
email: string;
age: number;
}

// Implement the following functions
// function getProperty<T, K extends keyof T>(...)
// function updateProperty<T, K extends keyof T>(...)
Hint
  1. With K extends keyof T, constrain K to the property names of T
  2. With T[K], get the type of the corresponding property
  3. updateProperty returns a new object with spread syntax
Answer and explanation
interface User {
id: number;
name: string;
email: string;
age: number;
}

// A function that gets a property
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

// A function that updates a property (immutably)
function updateProperty<T, K extends keyof T>(
obj: T,
key: K,
value: T[K]
): T {
return {
...obj,
[key]: value
};
}

const user: User = {
id: 1,
name: "Yamada Taro",
email: "yamada@example.com",
age: 30
};

// Using getProperty
const userName = getProperty(user, "name"); // string type
const userAge = getProperty(user, "age"); // number type
console.log(userName); // "Yamada Taro"
console.log(userAge); // 30

// A key that does not exist is an error
// getProperty(user, "invalid"); // Error

// Using updateProperty
const updated = updateProperty(user, "name", "Tanaka Hanako");
console.log(updated);
// { id: 1, name: "Tanaka Hanako", email: "yamada@example.com", age: 30 }

// A mismatched type is an error
// updateProperty(user, "name", 123); // Error: a string is required

// A key that does not exist is an error
// updateProperty(user, "invalid", "x"); // Error

Explanation:

  • With K extends keyof T, K is constrained to the valid property names of T
  • With T[K] (a lookup type), you can get the type of the corresponding property
  • This makes specifying a key that does not exist, or setting a value with a mismatched type, a compile error
  • updateProperty uses spread syntax to return a new object without changing the original (immutable)

Summary

What you learned in this chapter:

  • The keyof operator: get an object type's property names as a union type
  • Lookup types (T[K]): get the type of a property
  • Index signatures: define the type of an object with dynamic property names
  • Optional Chaining (?.): safely chain-access through null/undefined
  • Nullish Coalescing (??): apply a default value only for null/undefined
  • const assertions: make a value a literal type and read-only

By combining these features, you can write type-safe and maintainable code.

In the next chapter, you will learn the basics of generics.

Common pitfalls for beginners

warning

Common mistakes

❌ Trying to get the keys of a primitive type with keyof

// ❌ Using keyof on a primitive type
type StringKeys = keyof string;
// Result: a union type that includes number and built-in method names (number | "toString" | "charAt" | ...)

// It is likely not the type you intended
const key: StringKeys = "charAt"; // OK, but is it what you intended?

Cause: keyof gets an object type's keys, so using it on a primitive produces an unexpected result. Solution: Use keyof on object types (interfaces, type aliases).

❌ The fixed property type and the index signature are inconsistent

// ❌ The fixed property type does not match the index signature
interface BadConfig {
name: string;
count: number; // Error: incompatible with the index signature
[key: string]: string;
}

// ✅ Include the fixed property types in the index signature
interface GoodConfig {
name: string;
count: number;
[key: string]: string | number; // includes all value types
}

Cause: An index signature defines "the type of all properties", so a fixed property must be included in that type. Solution: Make the index signature's value type a union that includes the fixed property types.

❌ Not considering the result type of Optional Chaining

interface User {
profile?: {
age: number;
};
}

// ❌ Not considering the possibility of undefined
function getAge(user: User): number {
return user.profile?.age; // Error: number | undefined cannot be assigned to number
}

// ✅ Consider the undefined case
function getAgeSafe(user: User): number {
return user.profile?.age ?? 0; // set a default value
}

Cause: The result of ?. always has | undefined added. Solution: Set a default value with ??, or include | undefined in the return type.

❌ Forgetting as const and the type widens

// ❌ Without as const
const COLORS = ["red", "green", "blue"];
type Color = typeof COLORS[number];
// Color is the string type (not a literal type)

// ✅ Add as const
const COLORS_CONST = ["red", "green", "blue"] as const;
type ColorConst = typeof COLORS_CONST[number];
// ColorConst is "red" | "green" | "blue"

Cause: A normal array widens the element type (such as string[]), and literal types are lost. Solution: Add as const when you want to preserve literal types.

❌ Not understanding the difference between || and ??

// ❌ 0 and "" are also treated as false
function getCount(value: number | null): number {
return value || 10; // even value=0 returns 10
}

console.log(getCount(0)); // 10 (unintended behavior)
console.log(getCount(null)); // 10

// ✅ Replace only null/undefined
function getCountSafe(value: number | null): number {
return value ?? 10; // value=0 returns 0
}

console.log(getCountSafe(0)); // 0
console.log(getCountSafe(null)); // 10

Cause: || returns the default value for all falsy values (0, "", false, etc.). Solution: When you want to replace only null/undefined, use ??.


Move on to generics. You will learn how to design general-purpose, reusable type parameters and utility types.