A reusable function should keep the information that makes its result useful. If selecting the first user erases the user's fields, callers must recover them with assertions. If selecting a property loses its relationship to the requested key, every result becomes an inconvenient union. If public and internal user types are copied independently, a later field change can leave their contracts inconsistent. TypeScript's reusable types address these problems by expressing relationships instead of merely making types broader.
Read this chapter after How TypeScript Reasons About Your Code. We will carry object shapes through generic functions, connect keys to values, distinguish several forms of immutability, and derive related API contracts. Assume strict checking. Examples marked “compile error” intentionally show rejected operations. The type system describes these relationships during checking; runtime operations still need ordinary JavaScript implementations.
In this chapter: generic relationships, property selection, immutability and literals, and derived API contracts.
Generics preserve relationships
Consider selecting an element from an array. The algorithm does not depend on whether the elements are numbers, strings, or users. Its result does depend on that choice. A parameter typed any[] could accept all three, but would discard the connection between the input element and the output. A type parameter names that connection: use one T for both positions, and each call supplies its own concrete interpretation.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const a = first([1, 2, 3]); // number | undefined
const b = first(["a", "b"]); // string | undefined
first<number>([1, 2, 3]); // explicit argument usually unnecessary
T is neither a runtime variable nor a synonym for any. The implementation must work for the types admitted by its signature. The caller receives a result computed using the particular inferred type for that call. Type inference saves you from writing the argument explicitly when the inputs already give enough evidence. This preserves abstraction without forcing callers to surrender concrete information.
The undefined part expresses a different relationship: an arbitrary array may be empty. Ordinary array indexing can return undefined at runtime, so this signature makes that possibility visible even without noUncheckedIndexedAccess. The latter compiler option adds more conservative checks to indexed reads; it is not included automatically by strict. A truthful return signature should not depend on callers remembering that an empty array behaves differently.
Self-check: What is the complete inferred type of result? Is it any, and can you immediately read result.name?
const result = first([
{ id: 1, name: "A" },
{ id: 2, name: "B" }
]);
Reveal answer
result is { id: number; name: string } | undefined. T is the concrete object shape inferred from the elements, not any. Under strict null checking, check for undefined before reading name.
Sometimes the algorithm needs more than an arbitrary element. An unconstrained getId<T>(value: T) cannot read value.id: callers might supply a string or number. A constraint gives the implementation a minimum capability. T extends { id: number } requires a numeric ID while retaining the caller's entire inferred type. It therefore fits structural typing rather than replacing it with a named class requirement.
function getId<T extends { id: number }>(value: T) {
return value.id;
}
getId({ id: 42, name: "A" });
getId({ id: 7, price: 500 });
getId("hello"); // compile error
The distinction between the constraint and the chosen T explains why additional fields in a generic argument are accepted. A direct parameter of { id: number } asks a fresh literal to match that particular target and can trigger excess-property checks. A generic parameter lets inference choose the complete argument shape first, then checks that it meets the minimum. The constraint is a floor on available operations, not a transformation that removes the other fields.
Self-check: Will this compile, and what is T? What could the implementation safely assume if the constraint were removed?
getId({ id: 42, name: "A", age: 25 });
Reveal answer
It compiles with T inferred as { id: number; name: string; age: number }. The constraint only requires id: number. Without that constraint, accessing value.id is unsafe because T may have no such property.
There is also a useful design judgment here: getId returns only a number, so a non-generic parameter can be enough when preserving the rest of the input has no benefit. Generics earn their complexity when a relationship between positions matters. first needs that relationship; its caller benefits directly from the retained element type. The official function guidance explains generic relationships and constraints.
Connect property names to property values
A property selector needs two related inputs. object says that an input is a non-primitive value, but does not promise any particular named properties. string allows keys the object does not contain. Combining those two broad types cannot justify an indexed access under strict checking. The problem is missing information about their relationship, not a lack of a sufficiently forceful assertion.
function unsafeGet(obj: object, key: string) {
return obj[key]; // compile error: no arbitrary string index signature
}
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Himanshu", active: true };
get(user, "name"); // string
get(user, "active"); // boolean
get(user, "salary"); // compile error
For this object, keyof T is "id" | "name" | "active". The second type parameter must belong to that key set. Indexed access T[K] then selects the corresponding property type. For a literal "name", the result is string; for "active", it is boolean. Nothing is inspected by these type operators at runtime: the implementation is still an ordinary obj[key] read. Their role is to make the static contract accurately describe that read.
Self-check: What are a, b, and keyof typeof user? Why can b not subsequently receive a string?
const a = get(user, "id");
const b = get(user, "active");
Reveal answer
a is number, b is boolean, and the key union is "id" | "name" | "active". In the second call, K is "active", making T[K] boolean. With a mutable let b the inferred type would still reject a later string; this example's const also forbids rebinding.
The result can only be as precise as the key. If a key variable is typed "id" | "active", then its selected value is number | boolean; the function cannot know which member runtime execution will choose. This is the same principle as narrowing from the previous chapter: keep uncertainty when it is real, and use evidence to reduce it when available. Keyof and indexed access describe the two complementary operators.
Immutability and literal types
Preserving a relationship also depends on how much a value can change. A const declaration prevents replacing the binding; it does not freeze the object it references. For const config = { method: "GET" }, the method property is ordinarily inferred as string, allowing later assignment of "POST". In contrast, a primitive const method = "GET" can retain the literal type because that binding cannot later receive another string.
A readonly property restricts writes through a particular typed view. It does not make every nested value readonly. In the following configuration, the outer database property cannot be replaced, but the nested object still exposes a writable host. Track which operation the annotation actually restricts rather than treating readonly as a blanket promise about the whole object graph.
interface UserIdentity { readonly id: number; name: string }
const identity: UserIdentity = { id: 1, name: "Himanshu" };
identity.name = "A";
identity.id = 2; // compile error
interface Config { readonly database: { host: string } }
const config: Config = { database: { host: "old" } };
config.database.host = "new"; // allowed
config.database = { host: "new" }; // compile error
The restriction disappears from emitted JavaScript and need not stop changes made through a separate mutable alias. The same distinction matters for readonly arrays: the view allows observation without permitting changes to the array's slots or length. map is permitted because it produces another array. Numbers themselves are values; describing the array as readonly is about its structure, not creating a special immutable kind of number.
Self-check: Which operations are legal, and is mapped a readonly array merely because its input is readonly?
const arr: readonly number[] = [1, 2, 3];
const x = arr[0];
const mapped = arr.map(value => value * 2);
arr.push(4);
arr.pop();
arr[0] = 10;
Reveal answer
Reading and mapping are legal. push, pop, and indexed assignment fail because they mutate the array through this readonly view. mapped is a new mutable number[]. The annotation does not freeze the original array at runtime.
A const assertion combines literal preservation with readonly treatment of a literal expression. { method: "GET" } as const retains "GET" and a readonly property, rather than widening the property to string. An array literal becomes a readonly tuple with known positions. This is useful when one runtime list should also define the set of values allowed by the checker.
const requestConfig = { method: "GET" } as const;
requestConfig.method = "POST"; // compile error
const roles = ["admin", "user", "guest"] as const;
type Role = typeof roles[number];
Self-check: What is Role, and why is it more specific than string?
Reveal answer
typeof roles is readonly ["admin", "user", "guest"]. Numeric indexed access collects its element types, so Role is "admin" | "user" | "guest". Without literal preservation, a usual string[] would give only string.
as const is still not runtime freezing or universal deep immutability. If the literal contains a reference to an existing mutable array, that array remains mutable. Decide whether you need a restrictive typed view, preserved discriminants, or actual control over runtime mutation; those are different requirements. The const assertion release notes show these limitations.
Derive related API contracts
Once a canonical object type is established, utility types can express related contracts without copying every field. Partial makes its properties optional, Pick retains selected keys, and Omit excludes selected keys. These are transformations of a type, so they keep relationships to the original definition as it changes. They do not execute an update, create a new object, or remove a field from a response.
interface User {
id: number;
name: string;
email: string;
passwordHash: string;
}
type PublicUser = Omit<User, "passwordHash">;
type LoginCredentials = Pick<User, "email" | "passwordHash">;
function updateUser(id: number, patch: Partial<User>) {}
Here PublicUser contains the three ordinary fields, and LoginCredentials contains the two selected fields. Partial<User> permits an incomplete set of existing properties. Its empty object is also valid, so application requirements such as “a patch must change something” need additional checking. It is shallow: applying it to an object with a required nested object would make that outer property optional, without automatically making the nested object's fields optional.
Self-check: Does updateUser(1, { name: "Himanshu", age: 28 }) compile because email may be absent?
Reveal answer
No. Missing email is allowed, but the fresh literal's age is not a property of User. Partial changes requiredness; it does not introduce arbitrary keys or validate a patch received as JSON.
Use derived types to document what a consumer should see, then implement the corresponding runtime boundary deliberately. A full internal user can be structurally assigned to PublicUser while retaining passwordHash in the actual object. Sending that object unchanged would still send the secret. Selecting public fields when building the response turns the intended type contract into actual runtime behavior; an annotation alone cannot do that.
Self-check: Derive an API response type for this transaction that excludes the debug information. What else must the response code do?
interface Transaction {
id: string;
amount: number;
merchantId: string;
internalDebugInfo: string;
}
Reveal answer
Editorial answer, verified and supplied because the source question was unanswered before the conversation changed topic:
type TransactionResponse = Omit<Transaction, "internalDebugInfo">;
The serializer must also exclude that field from the actual response object. Omit changes the static shape, not the data being serialized.
The reusable pieces now have distinct jobs: generics carry caller information, constraints grant operations, key operators preserve property relationships, readonly limits a view's writes, and utility types derive related contracts. Apply each where its relationship serves the code. This lets the checker help you evolve a program while leaving validation, serialization, and mutation policy explicit at runtime.
Source: TypeScript-Inference-Practice.pdf, pp. 12–16 and 19–25. Supporting references: generics, readonly properties, utility types. Continue with How Asynchronous JavaScript Executes or return to TypeScript and Node Revision.