Learning path · Level 8 · Tutorial 5

Preserve Type Relationships with Generics

Write small reusable functions and contracts that preserve specific data types instead of losing information or duplicating nearly identical definitions.

  • Level: Intermediate
  • Duration: 20 min
  • 8 steps
Start tutorial Back to the path
Preserve Type Relationships with Generics

What you will go through

  1. Identify the Type Relationship Recognize the input-to-output type relationship that a reusable collection helper must preserve. 2 min
  2. Write a Type-Preserving Collection Function Declare and implement a generic filtering helper that keeps the caller's record type in its callback and returned array. 3 min
  3. Choose Inferred or Explicit Type Arguments Use TypeScript inference when a typed collection already identifies the element type, and provide a checked explicit type argument when an empty input does not. 2 min
  4. Preserve Types When a Lookup Can Fail Implement a generic predicate-based lookup that retains the caller's record type while accurately representing a missing match as undefined. 3 min
  5. Require a Property Without Losing the Record Type Add a minimum ID constraint to a generic lookup without losing caller-specific record information. 3 min
  6. Define Reusable Containers and Results Define generic collection and result contracts that preserve their contained record types, then narrow a successful result before accessing Product data. 3 min
  7. Use Generics Only Where They Add a Relationship Choose concrete or generic TypeScript contracts based on whether an API must preserve a caller-specific type relationship. 2 min
  8. Apply and Verify the Complete Relationship Combine constrained lookup, typed collections, and typed results in one local TypeScript workflow while preserving record-specific information. 3 min

What you will learn

  • Write a generic collection function that preserves the caller's record type in its result.
  • Use inferred or explicit type arguments and safely handle a lookup that returns undefined.
  • Add a simple constraint when a reusable function requires a particular property.
  • Define and consume a generic container or result contract.
  • Distinguish a useful generic relationship from a function adequately described by concrete types.

Before you start

  • Type Function Boundaries and Callbacks
  • Narrow Unions and Nullable Values

Step 1 of 8

Identify the Type Relationship

Recognize the input-to-output type relationship that a reusable collection helper must preserve.

Spot the repeated relationship

One operation, duplicated contracts

Two helpers may have the same algorithm but be tied to different record types. Here, each helper filters an array with a callback. The meaningful differences are repeated in three places: the array element type, the callback parameter type, and the result type.

Two type-specific helpers

These declarations use two separate contracts for the same filtering operation.

typescript
type Product = { id: string; name: string; inStock: boolean };
type User = { id: string; displayName: string };

function keepMatchingProducts(
  items: Product[],
  matches: (item: Product) => boolean
): Product[] {
  return items.filter(matches);
}

function keepMatchingUsers(
  items: User[],
  matches: (item: User) => boolean
): User[] {
  return items.filter(matches);
}

Keep each lane connected

A diagram showing product records filtered into product records and user records filtered into user records, with each type retaining its own details.

The filtering action is reusable, but each caller needs its own element type to remain connected from input to output.

See what a union throws away

Precision matters

A broad union can accept both shapes, but it does not say which shape comes back for a particular call. A reusable contract needs a relationship: when the caller supplies Product records, the result must be known as Product records; when the caller supplies User records, it must be known as User records.

A generic type parameter will express that caller-specific relationship in the next step. It is not a third record shape and does not change objects at runtime.

A broad annotation loses the connection

This widening is allowed, but it throws away useful compile-time information.

typescript
type Product = { id: string; name: string; inStock: boolean };
type User = { id: string; displayName: string };

const products: Product[] = [
  { id: "p1", name: "Desk Lamp", inStock: true },
  { id: "p2", name: "Notebook", inStock: false }
];

const productMatches: Product[] = products.filter(
  (item) => item.inStock
);

const broadMatches: (Product | User)[] = productMatches;

// broadMatches[0].inStock;
// TypeScript rejects this: a User might not have inStock.

Tip

Runtime objects vs. compile-time knowledge

broadMatches still points to Product objects at runtime, so those objects still have name and inStock. The annotation did not remove any property.

However, compile-time type checking must honor the declared Product | User element type. It only permits properties guaranteed on both alternatives, such as id. The lost information is the compiler's knowledge that this particular result came from Products.

Practice: preserve the caller's type

Check the relationship

Match each situation to the type information a reusable filtering helper should preserve or loses.

Tap an item and then its matching pair.

Step 2 of 8

Write a Type-Preserving Collection Function

Declare and implement a generic filtering helper that keeps the caller's record type in its callback and returned array.

Connect the caller's type to the result

One type parameter, linked positions

keepMatching needs one type parameter because one caller-specific element type appears in several places.

T is the declared type parameter. Within one call, it represents the array element type:

  • With a Product[], the predicate receives a Product and the result is Product[].
  • With a User[], the predicate receives a User and the result is User[].

The repeated T is the relationship: the helper does not replace a specific record type with a broad shared type.

The relationship preserved by T

A diagram shows Product records and User records using the same generic helper while each keeps its own fields from input through callback to output.

The same T connects the input items, each predicate item, and the returned matches.

Implement filtering without assuming fields

One reusable implementation

In src/generics.ts, create or replace the practice file with this code. The helper stays independent of record-specific properties: it passes each item to matches and returns the filtered array. The caller's callback can use the properties of its own record type.

Save the file and run npx tsc --noEmit from the project root. With no diagnostics, strict compile-time type checking accepts the relationships.

typescript
export {};

type Product = { id: string; name: string; inStock: boolean };
type User = { id: string; displayName: string };

const products: Product[] = [
  { id: "p1", name: "Desk Lamp", inStock: true },
  { id: "p2", name: "Notebook", inStock: false }
];

const users: User[] = [
  { id: "u1", displayName: "Avery" }
];

function keepMatching<T>(
  items: T[],
  matches: (item: T) => boolean
): T[] {
  return items.filter(matches);
}

const stockedProducts = keepMatching(products, (product) => product.inStock);
const productNames = stockedProducts.map((product) => product.name);

const namedUsers = keepMatching(users, (user) =>
  user.displayName.startsWith("A")
);
const userIds = namedUsers.map((user) => user.id);
Expected output
No terminal output; exit code 0.

Expected terminal result: no text is printed and the command exits successfully with code 0.

Tip

Keep the implementation general

Inside the helper body, T stands for an unspecified caller type, so the implementation should not assume fields such as inStock or displayName exist. Calling items.filter(matches) works for every T because both values use that same T relationship.

Apply the linked type relationship

Preserve both record types

Complete the generic declaration, filtering implementation, and the record-specific property reads available in each callback and returned array.

typescript
function keepMatching<>(
  items: T[],
  matches: (item: T) => boolean
): T[] {
  return items.(matches);
}

const stockedProducts = keepMatching(products, (product) => product.);
const productNames = stockedProducts.map((product) => product.);

const namedUsers = keepMatching(users, (user) =>
  user..startsWith("A")
);
const userIds = namedUsers.map((user) => user.);

Step 3 of 8

Choose Inferred or Explicit Type Arguments

Use TypeScript inference when a typed collection already identifies the element type, and provide a checked explicit type argument when an empty input does not.

Infer from an informative input

Inference connects one call's types

keepMatching declares one type parameter, T. At each call, TypeScript can often choose the matching type argument from a typed input.

Because products is Product[], this call infers T as Product. The callback parameter is a Product, and stocked is a Product[].

Because users is User[], the second call independently infers T as User. You do not need to write type arguments when the input already provides clear type information.

One declaration, caller-specific results

Diagram showing Product records remaining Product records and User records remaining User records as they pass through the same generic filter.

The caller's typed array gives TypeScript enough information to infer a different type argument for each call.

Let typed collections supply T

Two calls to the helper from the previous step use inference; no type argument is written.

typescript
const stocked = keepMatching(
  products,
  (product) => product.inStock
);

const matchingUsers = keepMatching(
  users,
  (user) => user.displayName.startsWith("A")
);

Make the type argument explicit only when needed

Use an explicit type argument for an empty input

You can supply a type argument after the function name when the intended element type is not clear from the values. An empty array literal has no record elements to communicate that intention.

Here, <Product> supplies the type argument. It makes the empty input a Product[] for this call and gives product the Product type in the predicate.

An explicit type argument is still checked. It is not a conversion: keepMatching<Product>(users, ...) is a compile-time error because users is User[], not Product[]. Runtime objects are not changed by a type argument.

State the intended record type

This call needs an explicit type argument because the empty array does not reveal its intended element shape.

typescript
const emptyProducts = keepMatching<Product>(
  [],
  (product) => product.inStock
);

Where the type information comes from

Compare how the function learns, or is given, the type argument.

Inference is preferred when a typed value already communicates the intended element type.
Call inputTypeScript's choice for TWhy
products: Product[]ProductThe array elements are typed.
users: User[]UserThe array elements are typed.
[]Not usefully determinedThere are no elements to describe.
keepMatching<Product>([])ProductThe supplied type argument sets the intended type.

Apply an explicit type argument

Practice: type an empty-input call

Complete the explicit type argument so this empty-input call filters the intended record type.

typescript
const selected = keepMatching<>(
  [],
  (product) => product.inStock
);

Decide when the relationship is valid

Check the complete decision

Which review correctly describes these three situations: filtering the typed products collection, filtering an empty array intended for product records, and attempting keepMatching<Product>(users, ...)?

Step 4 of 8

Preserve Types When a Lookup Can Fail

Implement a generic predicate-based lookup that retains the caller's record type while accurately representing a missing match as undefined.

A precise result can still be absent

One lookup, two possible runtime outcomes

A filtering helper always returns an array. A lookup is different: it returns one matching item, or it returns no item.

findMatching<T> uses the same type parameter in the input array, predicate, and return type. For a Product[] input, the result is Product | undefined. For a User[] input, it is User | undefined.

The union is honest about runtime behavior: a predicate can fail to match. At the same time, T preserves the caller's specific record type whenever an item is available.

The caller's record type is retained

A diagram with separate Product and User lookup paths. Each path can produce its own detailed record card or an empty result slot.

The input determines the available record type; the empty outcome is represented separately as possible absence.

Implement once and handle each outcome

Add the lookup without redeclaring existing data

Continue in the existing src/generics.ts file from the earlier steps. Keep its existing export {};, record contracts, sample collections, and keepMatching code. Add only the following snippet below the existing code—do not copy the earlier declarations again.

Array.find already returns an element or undefined, so the helper can return items.find(matches) directly. Each guard proves that a value exists before its record-specific property is read.

Generic lookup and safe callers

Add this incremental snippet below the code already in src/generics.ts.

typescript
function findMatching<T>(
  items: T[],
  matches: (item: T) => boolean
): T | undefined {
  return items.find(matches);
}

const foundProduct = findMatching(products, (product) => product.inStock);

if (foundProduct !== undefined) {
  console.log(foundProduct.name);
}

const missingProduct = findMatching(
  products,
  (product) => product.id === "p9"
);

if (missingProduct === undefined) {
  console.log("Product not found");
}

const foundUser = findMatching(users, (user) => user.id === "u1");

if (foundUser !== undefined) {
  console.log(foundUser.displayName);
}

Check the file

From the project root, save the file and run the local TypeScript compiler.

bash
npx tsc --noEmit
Expected output
No terminal output; command exits successfully with status 0.

Practice a type-preserving lookup

Complete the contract and guard

Complete the generic return contract and the availability check before reading the User-specific property.

typescript
function findMatching<T>(
  items: T[],
  matches: (item: T) => boolean
):  {
  return items.find(matches);
}

const foundUser = findMatching(users, (user) => user.id === "u1");

if () {
  console.log(foundUser.displayName);
}

Confirm the full relationship

Verify matching and nonmatching calls

Which fragment completes the generic lookup contract and correctly consumes both a matching Product lookup and the nonmatching lookup for "p9"? The two variables must have the same declared result type, and every Product-specific property read must handle absence first.

Step 5 of 8

Require a Property Without Losing the Record Type

Add a minimum ID constraint to a generic lookup without losing caller-specific record information.

Set the minimum shape

Why an unconstrained T is not enough

An unconstrained type parameter, written as <T>, could stand for any type. TypeScript therefore cannot assume an item has an id property, so item.id is not valid inside an unconstrained generic helper.

Define the smallest shape the lookup needs:

type Identified = { id: string };

Then write <T extends Identified>. This requires every accepted T to have a string id. It does not replace T with Identified: a caller can still supply richer records such as Product or User.

A minimum requirement, not a replacement

A minimal identifier requirement accepts richer product-like and user-like records while their unique fields remain attached.

The constraint requires a string identifier while the caller-specific record type remains intact.

Implement without losing T

Add a constrained ID lookup

Continue in the existing src/generics.ts file from the earlier steps. Keep its existing export {};, Product and User contracts, sample collections, keepMatching, and findMatching declarations. Add only this snippet below that existing code; do not redeclare earlier names.

The constraint lets the implementation compare item.id with the requested string ID. Because the return type is still T | undefined, each call retains its complete element type while honestly representing a missing match.

Constrained lookup and callers

Add this incremental snippet below the existing code in src/generics.ts.

typescript
type Identified = { id: string };

function findById<T extends Identified>(
  items: T[],
  id: string
): T | undefined {
  return items.find((item) => item.id === id);
}

const productById = findById(products, "p1");

if (productById !== undefined) {
  console.log(productById.name);
  console.log(productById.inStock);
}

const missingProductById = findById(products, "p9");

if (missingProductById === undefined) {
  console.log("Product not found");
}

const userById = findById(users, "u1");

if (userById !== undefined) {
  console.log(userById.displayName);
}

Tip

Verify the incremental change

Save the file, then run npx tsc --noEmit from the project root. A successful check prints no diagnostics. The calls infer different results: the product lookup is Product | undefined, while the user lookup is User | undefined.

After an undefined guard, the complete record type is available. The constraint gives the helper access to id; it does not discard name, inStock, or displayName.

Practice the signature

Complete the lookup contract

Complete the constrained generic signature for an ID lookup.

typescript
type Identified = { id: string };

function findById<T  Identified>(
  items: T[],
  id: string
):  {
  return items.find((item) => item.id === id);
}

Classify compatibility and preserved fields

Check the full type relationship

Which complete review of findById is accurate?

Step 6 of 8

Define Reusable Containers and Results

Define generic collection and result contracts that preserve their contained record types, then narrow a successful result before accessing Product data.

Reuse a container without losing item types

One container shape, specific contents

A generic container keeps its contained type connected to the container contract. In Collection<T>, the type parameter appears in items: T[].

Collection<Product> therefore has Product[] items, while Collection<User> has User[] items. Both use the same reusable shape. total is always a numeric item count, so it does not need T.

The contained type stays specific

Two reusable containers preserve whether their record cards are Product-shaped or User-shaped.

The container shape is reusable; its item type remains specific.

Add reusable collection values

Continue in the existing src/generics.ts file from the earlier steps. Keep the existing declarations, sample collections, and helpers. Add only this snippet below the existing code; do not redeclare Product, User, products, or users.

typescript
interface Collection<T> {
  items: T[];
  total: number;
}

const productCollection: Collection<Product> = {
  items: products,
  total: 2
};

const userCollection: Collection<User> = {
  items: users,
  total: 1
};

Carry the type through a result

Model success or failure

A result can represent success or failure while preserving the success value's type. In Result<T>, only the ok: true variant carries data: T. The ok: false variant carries an error string instead.

Check ok before reading data. This is the discriminant narrowing technique you already know. With Result<Product>, data in the successful branch is a Product; with Result<User>, it is a User.

Only one branch carries the record

A result splits into a success path carrying a Product record and a failure path carrying an error message but no record.

The successful branch carries T as data; the failure branch carries an error string instead.

Add a generic result contract

Add this below the Collection<T> snippet. These distinct variable names avoid conflicts with the lookup variables from earlier steps. The if statements narrow each result before a branch-specific property is read.

typescript
type Result<T> = { ok: true; data: T } | { ok: false; error: string };

const savedProductResult: Result<Product> = {
  ok: true,
  data: { id: "p1", name: "Desk Lamp", inStock: true }
};

const missingProductResult: Result<Product> = {
  ok: false,
  error: "Product not found"
};

const savedUserResult: Result<User> = {
  ok: true,
  data: { id: "u1", displayName: "Avery" }
};

if (savedProductResult.ok) {
  console.log(savedProductResult.data.name);
}

if (!missingProductResult.ok) {
  console.log(missingProductResult.error);
}

if (savedUserResult.ok) {
  console.log(savedUserResult.data.displayName);
}

Check all additions

After adding both the Collection<T> and Result<T> snippets, save src/generics.ts and run the project-local compiler from the project root.

bash
npx tsc --noEmit
Expected output
No diagnostics; command exits successfully.

Check the type relationships

Connect contracts to their contents

Match each generic instantiation or result branch to what it guarantees.

Tap an item and then its matching pair.

Apply the container and result contracts

Build a type-preserving Product workflow

Complete both generic contracts. Then complete the narrowing check so the function logs the Product Boolean stock value only in the successful result branch.

typescript




function showProductStatus(
  practiceProductResult: Result<Product>
): void {
  if (practiceProductResult.) {
    console.log(practiceProductResult.data.);
  }
}

Step 7 of 8

Use Generics Only Where They Add a Relationship

Choose concrete or generic TypeScript contracts based on whether an API must preserve a caller-specific type relationship.

Decide what must remain connected

Look for a relationship

A generic is useful when one caller-specific type must stay connected to another part of an API.

For example, keepMatching receives T[], gives T to its callback, and returns T[]. If the caller supplies Product[], the result remains Product[].

Do not add T merely because an input is a record. Ask: Does the caller need a specific type preserved somewhere else? If not, a concrete contract is usually clearer.

Two API shapes

A comparison diagram: a concrete ID extraction reduces a rich record to one string, while a generic filtering path preserves the same detailed record shape from input to output.

Use a generic when the same caller-specific shape must continue through the operation. Use a concrete contract when the output does not need that shape.

Choose the smallest useful contract

A concrete contract can be enough

getId needs only one capability: an item with a string id. It returns a string, so it does not need to preserve the caller's full record type.

A Product can be passed to this concrete parameter because structural typing permits a richer record where an Identified record is required. In contrast, filtering returns records, so keepMatching uses T to preserve their complete type.

Concrete extraction versus generic filtering

Compare a concrete operation with a type-preserving operation.

typescript
type Identified = { id: string };
type Product = { id: string; name: string; inStock: boolean };

function getId(item: Identified): string {
  return item.id;
}

function keepMatching<T>(
  items: T[],
  matches: (item: T) => boolean
): T[] {
  return items.filter(matches);
}

const products: Product[] = [
  { id: "p1", name: "Desk Lamp", inStock: true },
  { id: "p2", name: "Notebook", inStock: false }
];

const productId: string = getId(products[0]);
const availableProducts: Product[] = keepMatching(
  products,
  (item) => item.inStock
);

Contract choices

Choose the smallest contract that expresses the job.

A type parameter earns its place by preserving a meaningful connection.
NeedAppropriate contractConnection preserved
Extract an ID`Identified` → `string`None needed
Filter records`T[]` → `T[]`Input, callback, and result
Store typed items`Collection<T>`Contained item type
Store success data`Result<T>`Successful data type

Separate compile-time checking from runtime behavior

Generics do not validate runtime values

Generics describe relationships for compile-time type checking. They do not add a runtime check to JavaScript.

For example, getId and keepMatching help the compiler check code written against their contracts. At runtime, their type annotations are erased; a generic annotation cannot verify that data arriving from outside the application really has the expected shape.

Use generics to preserve information in typed code. Treat runtime validation as a separate boundary concern.

Tip

Decision rule

A type parameter should make a relationship clearer—not make a signature look more advanced.

Apply the decision rule

Choose the appropriate contract

Match each API need to the most accurate contract or statement.

Tap an item and then its matching pair.

Step 8 of 8

Apply and Verify the Complete Relationship

Combine constrained lookup, typed collections, and typed results in one local TypeScript workflow while preserving record-specific information.

Trace the complete workflow

Follow the preserved type

A useful generic relationship can survive an entire workflow. Collection<Product> supplies Product items; findById returns the same record type or undefined; then Result<Product> keeps that Product type in its successful branch.

The constraint on findById stays small: its implementation needs an id, not every Product property. Since T remains the caller’s complete type, an available Product still has name and inStock. The same lookup also accepts User records and preserves displayName.

These are compile-time type checking relationships. They help TypeScript check code that uses values; they do not validate unknown external data at runtime.

One relationship, end to end

Diagram showing Product records moving from a collection through an ID lookup to either a typed success result or an absence outcome, while a User record can use the same lookup.

The lookup requires only an ID, while its type parameter carries each caller’s richer record type through the workflow.

Prepare the integrated example

Replace the practice file

For this final local exercise, replace the entire current contents of src/generics.ts. Do not append these declarations below code from earlier steps, because that would create duplicate names.

The next two snippets form one complete replacement file. First select all content in src/generics.ts and paste Part 1. Then paste Part 2 immediately below it. The temporary function bodies type-check, but do not yet perform the intended lookup or result conversion.

Contracts and sample data

src/generics.ts — Part 1 of 2

typescript
export {};

type Product = { id: string; name: string; inStock: boolean };
type User = { id: string; displayName: string };
type Identified = { id: string };

interface Collection<T> {
  items: T[];
  total: number;
}

type Result<T> = { ok: true; data: T } | { ok: false; error: string };

const products: Product[] = [
  { id: "p1", name: "Desk Lamp", inStock: true },
  { id: "p2", name: "Notebook", inStock: false }
];

const users: User[] = [
  { id: "u1", displayName: "Avery" }
];

const productCollection: Collection<Product> = {
  items: products,
  total: 2
};

const userCollection: Collection<User> = {
  items: users,
  total: 1
};

Workflow scaffolding

src/generics.ts — Part 2 of 2

typescript
function findById<T extends Identified>(
  items: T[],
  id: string
): T | undefined {
  return undefined;
}

function toProductResult(value: Product | undefined): Result<Product> {
  return { ok: false, error: "Not implemented" };
}

const foundProduct = findById(productCollection.items, "p1");
const missingProduct = findById(productCollection.items, "p9");
const foundResult = toProductResult(foundProduct);
const missingResult = toProductResult(missingProduct);
const foundUser = findById(userCollection.items, "u1");

if (foundResult.ok) {
  console.log(foundResult.data.name);
}

if (!missingResult.ok) {
  console.log(missingResult.error);
}

if (foundUser !== undefined) {
  console.log(foundUser.displayName);
}

Practice the connected contracts

Complete the workflow

Complete the constrained lookup and the conversion from a possibly absent Product to a Product result.

typescript
function findById<T extends Identified>(
  items: T[],
  id: string
): T | undefined {
  return ;
}

function toProductResult(value: Product | undefined): Result<Product> {
  if () {
    return ;
  }

  return ;
}

Verify and conclude

Summary

Decision rule

  • Use a type parameter when a caller-specific type must stay connected across inputs, callbacks, outputs, or contained data.
  • Constrain a type parameter only to properties that the implementation actually reads.
  • Represent a lookup that may fail with T | undefined, then narrow before using record-specific properties.
  • A generic container or result contract preserves its contained type; abstraction alone is not a reason to add a generic.
  • Generic annotations express compile-time relationships, not runtime validation.

Run the type check

After replacing the two temporary function bodies with your completed code, save src/generics.ts and run this command from the project root.

bash
npx tsc --noEmit
Expected output
No terminal output; command exits successfully with status 0.

A successful TypeScript check is silent: the command prints no terminal text and exits with status 0.

Verify on your computer

In your local src/generics.ts, complete the two temporary function bodies using the prior exercise. Keep the Product lookups for "p1" and missing "p9", plus the User lookup for "u1". Run the type-check command.

Write a short verification report based on editor hover information and the compiler result. State the inferred types of the two Product lookup variables and the User lookup variable, the type of the two Product result variables, which outcome each Product ID follows, and whether the compiler reported diagnostics.

Write at least 180 characters (0/180).

Tutorial complete

Congratulations! You completed: Preserve Type Relationships with Generics

You can now use generics to preserve meaningful type relationships through reusable functions and contracts.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate