Getty Images

Using generics in Typescript: An in-depth tutorial

Generic variables give the TypeScript language versatility and compile-time type safety that put it on par with Java, C# and C++. These examples show how to use TypeScript generics.

Among the benefits of the Typescript programming language is that it grants developers the advanced features of object-oriented programming. One of these features is generic variables, also referred to as generics.

A generic variable is a way to define a type that will be officially declared later. This provides a new dimension to object-oriented programming: it increases versatility in terms of reuse and promotes compile-time type safety.

This article extends a basic introduction to TypeScript generics and demonstrates how to use them with several examples. Readers should have a basic understanding of Typescript as an object-oriented programming language. For reference, a GitHub repository contains all the code used in these examples.

The case for generics

A generic variable provides a way to declare a type that will be named later at design time.

When a generic variable is declared as part of a class declaration, the variable can be used internally within the class or as part of a class method declaration. Also, one can use a generic variable in a method without the need to specify the generic variable in the class declaration.

You declare a generic variable in a class declaration as follows.

export class MyClass<T>{
…. Some code
}

In that code, brackets indicate a declaration of a generic variable or variables, and T is the name of a generic variable.

Generics can be useful as more than a placeholder for a type. They also provide a way to constrain and manage the type that is eventually assigned to the given generic. For example, one can create a simple array in TypeScript to store various types of data, as shown in Example 1.

const anyArr = new Array();

anyArr.push(1);
anyArr.push('one');
anyArr.push(2);
anyArr.push('two');
Example 1. A simple array will accept any type.

One problem with the above code is that the elements in the array cannot be easily traversed to determine the sum of their values. You'd have to do a type-check on each element first and then add it to the sum.

However, one can create an array using a generic declaration so the array only stores numbers, and the elements can be traversed with no need for type checking. Example 2 shows how.

const numArray = new Array<number>();

numArray.push(1);
numArray.push(2);

const i:number;

numArray.forEach(num => {i + num});

console.log("The sum is ", i)

Output

The sum is 3
Example 2. Creating an array using a generic declaration.

In fact, adding a string value to numArray generates a compile-time error before the code runs. In Example 3, we add the statement at Line 5.

const numArray = new Array<number>();

numArray.push(1);
numArray.push(2);
numArray.push('three');  //line 5

const i:number;

numArray.forEach(num => {i + num});

console.log("The sum is ", i)
Example 3. Creating a compile-time error in an array that uses a generic.

This produces a compile time error.

TSError: ⨯ Unable to compile TypeScript:
src/typedArray.ts:5:15 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

numArray.push('three');

Generics are particularly useful when used to declare and work with specific types.

Example 4 creates an array of IPerson interface implementations and then processes the elements in the array accordingly. The IPerson interface and the Randomizer.getRandomPerson() class and function are both custom codes created for this article, viewable at the GitHub repository.

const persons = new Array<IPerson>();

persons.push(Randomizer.getRandomPerson());
persons.push(Randomizer.getRandomPerson());
persons.push(Randomizer.getRandomPerson());

persons.forEach(person => {
  console.log(`${person.firstName} ${person.lastName}`);
})

Output:

Winnifred Boyle
Titus Towne
Jazmyne Kautzer
Example 4. Traversing a strong typed array declared with a generic.

As you can see in Example 4, the persons array is declared to store implementations of the IPerson interface on the first line. The IPerson interface is defined to have firstName and lastName properties. You can see this in the code on the GitHub repository.

Because the structure of the IPerson interface is known at design-time, a developer can write code that inspects the properties of each IPerson implementation in the array. In the case of the persons array shown above, the code inspects the firstName and lastName properties and outputs the result to the console.

Generics provide a way to create code that accommodates strong type declaration at design time and type safety at compile time.

Using generics in a class

As mentioned previously, under TypeScript, generic variables can be defined at the class level. Example 5 below defines a class named DocumentProcessorV1, which defines a generic variable named T.

The purpose of the class DocumentProcessorV1, as the name implies, is to process documents. The document that DocumentProcessorV1 will process is of the type instance assigned to the generic variable T.

The actual instance of type T is passed as a parameter of the class constructor at Line 3. The parameter name is document. The value passed in the constructor parameter is assigned to a class variable which is also named document. The class variable document is defined on Line 2.

export class DocumentProcessorV1<T>{
  document: T
  constructor(document: T) { //line 3
    this.document = document
  }

  public process(): void{  //line 7
    if ((this.document as IWebDocument).url !== undefined) {
      // Process as a web document
      const d = (this.document as IWebDocument);
      console.log("A web document:", d.url);
    } else if ((this.document as IPrintDocument).pageCount !== undefined) {
      // Process as a print document
      const d = (this.document as IPrintDocument);
      console.log("A print doc with page count:", d.pageCount);
    } else {
      throw new Error('Unsupported type for DocumentProcessor');
    }
  }
} // line 19
Example 5. A class the supports a generic.

The generic variable T that represents the document to be processed can be of any type. Moreover, when a type is assigned at design time, this does not trigger a compilations error. However, the logic of the class is such that DocumentProcessorV1 only processes implementations of the IWebDocument or IPrintDocument interface. As a result, the only implementations of IWebDocument and IPrintDocument are supported by the class DocumentProcessorV1 is enforced in the DocumentProcesorV1.process() method.

In the DocumentProcesorV1.process() method on Lines 7-19, the logic in the method inspects the class variable named document to see if the variable supports the property named url, which is defined as a property in the IWebDocument interface, or the property pageCount, which is defined as a property of the IPrintDocument.

If the variable supports neither property, then the instance type assigned to the generic variable T is unacceptable and this generates a runtime error.

Example 6 shows two instances of the class DocumentProcessorV1.

Notice how the instance webProcessor declares IWebDocument as the generic variable type and passes the webDocument instance as a parameter of the class constructor. Essentially the code is saying, "Create an instance of DocumentProcessorV1 that supports an implementation of IWebDocument -- and by the way, that implementation is webDocument."

const webDocument: IWebDocument = Randomizer.getWebDocument();

const printDocument: IPrintDocument =
    Randomizer.getPrintDocument()

const webProcessor =
    new DocumentProcessorV1<IWebDocument>(webDocument);
webProcessor.process();

const printProcessor =
    new DocumentProcessorV1<IPrintDocument>(printDocument);
printProcessor.process();
Example 6. Two instances of the DocumentProcessor class, each with different types assigned to the class's generic variable T.

Example 6 follows the same logic, creating an instance called printProcessor that supports the IPrintDocument interface. The code produces the following expected result.

A web document: https://flippant-plough.name/
A print document with page count: 87

However, we're not out of the woods yet. In the following example, we create and run an instance of DocumentProcesorV1 that assigns the IPerson interface for the generic value T, but this triggers a runtime error.

const person = Randomizer.getRandomPerson();

const processor =
  new DocumentProcessorV1<IPerson>(person);
processor.process();

/Users/reselbob/Projects/AdvancedOops02_TS/src/document/DocumentProcessorV1.ts:20
      throw new Error('Unsupported type for DocumentProcessor')
            ^
Error: Unsupported type for DocumentProcessor
    at DocumentProcessorV1.process (/Users/reselbob/Projects/AdvancedOops02_TS/src/document/DocumentProcessorV1.ts:20:13)
Example 7. In an instance of DocumentProcesorV1, assigning the IPreson interface for the generic value T triggers a runtime error.

The fact that this is a runtime error is important because runtime errors go against the sprint of generics. Generics give programmers the versatility to declare the actual type of a generic variable at design time, but a more significant benefit is that they impose type safety at compile time before the code moves into a production environment.

It is better if code goes astray when it's compiled versus when it is operational. Troubleshooting runtime errors can be laborious and heartbreaking, while deburring compile-time errors is much easier.

The way generics impose compile-time type safety involves using constraints. The next section explains how that works.

Constraining generics

A TypeScript constraint is technique by which a programmer applies one or more rules to a generic variable. A constraint is particularly useful when you want to restrict a type to apply to a generic variable. Example 8 below demonstrates the technique.

export class DocumentProcessorV1<T extends IWebDocument | IPrintDocument>{
  document: T
  constructor(document: T) {
    this.document = document
  }

  public process(): void {
    if ('url' in this.document) {
      // Process as a web document
      const d = this.document as IWebDocument;
      console.log("Processing as a web document:", d.url);
    } else if ('pageCount' in this.document) {
      // Process as a print document
      const d = this.document as IPrintDocument;
      console.log("A print document with page count:", d.pageCount);
    }
  }
}
Example 8. Adding constraints to a generic variable.

Notice how the added statement T extends IWebDocument | IPrintDocument refactors the class DocumentProcessorV1. With this, the developer declares a constraint rule that says only an implementation of the IWebDocument or IPrintDocument interfaces can be assigned to the generic variable T at design time.

In Example 9, when we attempt to use an instance of an interface that is not acceptable to the constrained generic variable T -- in this case, an instance of the IPerson interface -- the compiler throws an error.

const person = Randomizer.getRandomPerson();

const processor =
  new DocumentProcessorV1<IPerson>(person);
processor.process();

TSError: ⨯ Unable to compile TypeScript:
src/badclasscode.ts:8:27 - error TS2344: Type 'IPerson' does not satisfy the constraint 'IWebDocument | IPrintDocument'.
  Type 'IPerson' is missing the following properties from type 'IPrintDocument': pageCount, title, text, author
Example 9. Using an instance of an interface that is unacceptable to the constrained generic variable T triggers a compile-time error.

Notice that the error thrown is a compile-time error, not a runtime error. This is because the generic variable T was constrained, and the error was detected during code compilation, not when the code was run.

Applying generics to a function

Constraints can also help declare generic variables only at the method level, which avoids the need to make a generic variable part of the class declaration. The following example demonstrates the technique.

Notice that that the code uses no generic to declare the class DocumentProcessorV2 on the first line. However, on the second line, a constrained generic variable T is declared against the process() method using the extends keyword. Also, the generic variable T defines the type of the document method parameter.

export class DocumentProcessorV2 {
  public process<T extends IWebDocument | IPrintDocument>(document: T): void {
    if ('url' in document) {
      // Process as a web document
      const d = (document as IWebDocument);
      console.log("Processing as a web document:", d.url);
    } else if (('pageCount' in document)) {
      // Process as a print document
      const d = (document as IPrintDocument);
      console.log("A a print document with page count:", d.pageCount);
    } else {
      throw new Error('Unsupported type for DocumentProcessor:')
    }
  }
}
Example 10. Apply a generic at the function level.

Example 11 shows how to use the DocumentProcessorV2 class declared in Example 10.

const webDocument: IWebDocument = Randomizer.getRandomWebDocument();
const printDocument: IPrintDocument = Randomizer.getRandomPrintDocument()

const webProcessor = new DocumentProcessorV2();
webProcessor.process<IWebDocument>(webDocument);

const printProcessor = new DocumentProcessorV2();
printProcessor.process<IPrintDocument>(printDocument);
Example 11: Using a class method that's defined with a generic at the method level.

Constraining generic variables at the method level provides a certain degree of efficiency for those classes that require using a generic only for a method. There's no need to incur the programming overhead from supporting generics at the class level.

Using generics in a method parameter and a method return

Finally, let's look at how to define a class that has a method that uses a generic variable in a parameter, as well as a generic variable that defines the return type of the method.

The code in Example 12 below for the class DocumentProcessorV3 builds on the demonstration code shown earlier in Example 9. However, it does not declare a single generic variable T at the class level that represents the document that the class will process. Instead, there is an additional second generic variable named V, which represents a type that describes the confirmation information returned by the method.

Notice the generic variable V is constrained to accept on an implementation of an IWebConfirmation or IPrintConfirmation interface.

export class DocumentProcessorV3
    <T extends IWebDocument | IPrintDocument,
     V extends IWebConfirmation | IPrintConfirmation> {

  public process(document: T): V | undefined {
    if ('url' in document) {
      // Process as a web document
      const d = (document as IWebDocument);
      console.log("Processing as a web document:", d.url);
      // create the confirmation
      const confirm: IWebConfirmation = {
        timeStamp: new Date(),
        documentId: d.id,
        title: d.title,
        url: d.url
      }
      return confirm as V;
    } else if (('pageCount' in document)) {
      // Process as a print document
      const d = (document as IPrintDocument);
      console.log("A print document with page count:", d.pageCount);
      // create the confirmation
      const confirm: IPrintConfirmation = {
        timeStamp: new Date(),
        documentId: d.id,
        title: d.title,
        pageCount: d.pageCount
      }
    return confirm as V;
    }
  }
}
Example 12. DocumentProcessorV3 is a refactored class that adds a second generic variable V that represent a confirmation returned by the class' process(document: T) method.

The code for executing the class' process() method is shown below along with the result of using implementations of both the IWebDocument and IPrintDocument interfaces.

The following code demonstrates how to use the DocumentProcessorV3 class with both a IWebDocument/IWebConfirmation pair as well as IPrintDocument/IPrintConfirmation pair.

const webDocument: IWebDocument = Randomizer.getRandomWebDocument();
const printDocument: IPrintDocument = Randomizer.getRandomPrintDocument()

const webProcessor =
  new DocumentProcessorV3<IWebDocument, IWebConfirmation>();

const webResult = webProcessor.process(webDocument);
console.log({webResult})

const printProcessor =
  new DocumentProcessorV3<IPrintDocument, IPrintConfirmation>();
const printResult =  printProcessor.process(printDocument);
console.log({printResult})

The result of the calls are as follows.

A web document: https://glass-fava.org/
{
  webResult: {
    timeStamp: 2024-03-27T13:47:49.385Z,
    documentId: '38a749ee-43a1-4af6-8220-91685aaf6994',
    title: 'molestiae textilis terreo adsuesco',
    url: 'https://glass-fava.org/'
  }
}
A print document with page count: 39
{
  printResult: {
    timeStamp: 2024-03-27T13:47:49.389Z,
    documentId: '52af3c98-a6a4-437c-9795-403cab64349b',
    title: 'credo cras conqueror ager',
    pageCount: 39
  }
}

Putting it all together

Generics bring a new level of versatility to working with types. The power of constraints grants developers the ability to restrict the actual types assigned to a generic variable at design time. As a result, generics add a new dimension to object-oriented programming and put TypeScript on par with other full powered languages such as Java, C# and C++.

However, as with any advanced language feature, generics take time to master. Hopefully the demonstration code and the commentary provided in this article will provide a solid foundation upon which to evolve your programming skills using generics.

Bob Reselman is a software developer, system architect and writer. His expertise ranges from software development technologies to techniques and culture.

Dig Deeper on Core Java APIs and programming techniques

App Architecture
Software Quality
Cloud Computing
Security
SearchAWS
Close