Introduction to TypeScript (Part 4)

Part 4 of a Four-Part Guide for Experienced JavaScript Developer

Ashok Khanna
7 min readMay 29, 2022

Welcome to the last part in our four-part guide on TypeScript!

In Part 1 we introduced the basic concepts of TypeScript, in Part 2 we stressed how to think in TypeScript, whilst in Part 3 we covered many useful concepts for function, object and class typing. We are now in the home stretch and will cover the slightly advanced but ultimately very central concept of generic types and type manipulation.

The main aim of generic typing and type manipulation is to express complex operations and values in a succinct, maintainable way (picture yourself refactoring entire modules and wanting to cascade down changes .

Generic Types

It’s common to write a function where the types of the input relate to the type of the output, or where the types of two inputs are related in some way. In TypeScript, generics are used when we want to describe a correspondence between two values. We do this by declaring a type parameter in the function / object / class signature.

Generic Functions

Below is an example of a generic function that utilises a generic type.

function firstElement<Type>(arr: Type[]): Type | undefined {
return arr[0];
}

By adding a type parameter Type to this function and using it in two places, we’ve created a link between the input of the function (the array) and the output (the return value). Now when we call it, a more specific type comes out:

// In the below s is of type 'string':
const s = firstElement(["a", "b", "c"]);
// In the below n is of type 'number'
const n = firstElement([1, 2, 3])
// In the below u is of type 'undefined'
const u = firstElement[]

Type Inference

What is especially interesting in the above examples is that we did not have to specify the type when calling the generic function — TypeScript can infer the type from the arguments passed in.

Multiple Generic Types

Note that we can have multiple generic types within a type signature:

function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
return arr.map(func);
}

Constraining Generic Types

In the above examples, any type could be used in palce of the generic. If we want to constrain the generic type, we do something the following. In this example, the generic type must have the property length:

function longest<Type extends { length: number }>(a: Type, b: Type) {
if (a.length >= b.length) {
return a;
} else {
return b;
}
}

Note about Using Generic Types & Functions

Writing generic functions is fun, but don’t forget their purpose — to observe and annotate a relationship between multiple types in a signature. If this is not present, then there isn’t much point to using generics and as a golden rule generic type parameters should appear at least twice in a type declaration.

As an aside, this is an important reflection about TypeScript itself. The language is meant for adding additional information about what values can and can’t be accepted in various concepts, that is not readily inferred from the code itself. By doing so, we increase the robustness of our code (due to static type checking) and make it easier for others (or ourselves in the future) to understand what is going on as we are explicitly narrowing down and documenting the possible paths various programming flows can take.

Some more notes on Generic Functions

The below is an illustrative example of how the placement of the generic Type affects its meaning.

interface GenericFunction1<Type> {
(arg: Type): Type
}
interface GenericFunction2 {
<Type>(arg: Type): Type;
}

Note how the placement of the generic Type modifies the meaning of these declarations:

// myFunction1 must accept arguments of type numberlet myFunction1: GenericFunction1<Number>// myFunction2 must return the same argument as that passed inmyFunction2: GenericFunction2

Generic Object Types

Back to learning TypeScript syntax, lets start looking at how we can use generics in other contexts. First up, we can define generic interfaces and types as follows.

interface Box<Type> { contents: Type }type Box<Type> = { contents: Type }// Either of the above will allow you to do:
let box: Box<string>

Note that since type aliases, unlike interfaces, can describe more than just object types, we can also use them to write other kinds of generic helper types.

type OrNull<Type> = Type | null;

type OneOrMany<Type> = Type | Type[];

type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;

type OneOrManyOrNull<Type> = OneOrMany<Type> | null

type OneOrManyOrNullStrings = OneOrManyOrNull<string>;

type OneOrManyOrNullStrings = OneOrMany<string> | null

Array Types

You can now hopefully see that Array<number> and Array<string> are generic types, whose equivalent shorthands are number[] and string[].

Generic Classes

Below is an example of a generic class, following similar syntax as the above. When a generic class is instantiated with new, its type parameters are inferred the same way as in a function call.


class Box<Type, NumType> {
contents: Type;
add: (x: NumType, y: NumType) => NumType;
constructor(value: Type) {
this.contents = value;
}
}

const b = new Box("hello!");

Type Manipulation

Type Manipluation is a related concept to generic types. TypeScript provides us a way to express types in terms of more primitive types, and similar to how we abstract away our code into modules and components, allow us to do the same with types — effectively type programming. The benefit of this is twofold:

  • By combining various types to express complex operations, we make our type system very maintable as we can modify certain parts and allow the changes to cascade down to all other types that rely on those parts
  • The more specialised our types get, the more bugs and incorrect programming flows we can pick up at compile time and not run time. This helps make our programs more robust (although type checking is not a panacea for all runtime bugs — just a tool to help reduce them)

We already discussed generic types, and these are used heavily in building our own custom types and type systems. We will discuss some additional building blocks here. Note that some of these ideas are a bit advanced, so feel free to refer back to them when you are more familiar with TypeScript. Also note that all these ideas (and indeed everything in this four-part guide) are meant to be combined with each other — take these building blocks and build something beautiful! By themselves, they do look a bit disjointed but think of the possiblities.

Keyof Type Operator

The keyof operator takes an object type and produces a string or numeric literal union of its key:

type Point = { x: number; y: number };// This is the same as type p = "x" | "y"
type P = keyof Point;

If the type has a string or number index signature, keyof will return those types instead:

type Arrayish = { [n: number]: unknown };// same as type A = number;
type A = keyof Arrayish;

Typeof Type Operator

We can use the typeof operator in a type context to refer to the type of a variable or property:

let s = "hello";// same as let n: string
let n: typeof s;

Indexed Access Types

We can use an indexed access type to get the type of a property on another type (note the last example of how we start combining multiple type concepts together — try other combinations yourself too!):

type Person = { age: number; name: string; alive: boolean };// Same as type Age = number
type Age = Person["age"];
// Same as type I1 = string | number
type I1 = Person["age" | "name"];
// Same as type I2 = string | number | boolean
type I2 = Person[keyof Person];

We can use number to get the type of an array’s elements, combining it with the typeof operator:

const MyArray = [ { name: "Alice", age: 15 },
{ name: "Bob", age: 23 },
{ name: "Eve", age: 38 }, ];
// Same as type Person = { name: string, age: number }
type Person = typeof MyArray[number];
// Same as type Age = number
type Age = typeof MyArray[number]["age"]

Conditional Types

Conditional types help describe the relation between the types of inputs and outputs. They take the form of SomeType extends OtherType ? TrueType : FalseType:

type Example1 = Dog extends Animal ? number : string;

In the above example, when Dog is assignable to Animal then Example1 has type number, otherwise it has type string.

There are some more points when it comes to conditional types (e.g. using the infer operator), but we’ll stop here for now and refer you to the official documentation when you are ready to dig into these further.

Mapped & Template Literal Types

Once you get more familiar with TypeScript and start using it more (remember — don’t worry if you don’t use every concept at the start, just start coding in TypeScript and you will naturally improve over time and tighten your static typing), it is worthwhile reading about mapped types and template literal types.

Rounding it Out: Importing & Exporting Types

To round out this part (and the entire guide!), lets show how to import and export types across files. Fortunately, it is much in the same way as importing and exporting in modern ECMAScript:

export type Cat = { breed: string; yearOfBirth: number };import type { Cat, Dog } from "./animal.js";

Conclusion

We have now reached the end of our four part guide. Hopefully some of it makes sense. The best way to learn is to use TypeScript, so do try and use it in your projects and hopefully within a few days or weeks you will start to appreciate how good it is and why I went to the trouble of writing this guide to encourage you to use it. I really enjoy programming in TypeScript and hope you do too.

--

--

Ashok Khanna
Ashok Khanna

Written by Ashok Khanna

Masters in Quantitative Finance. Writing Computer Science articles and notes on topics that interest me, with a tendency towards writing about Lisp & Swift

No responses yet