📜👓

Slightly Advanced TypeScript — Part 1: Generic, Mapped & Conditional Types

Contents

Introduction

I’m sure you have heard a lot of people love TypeScript’s highly expressive type system, but you also might have heard a lot of hate around TypeScript when it comes to “Type Gymnastics”. Well, welcome to the gym. Love it or hate it, TypeScript might stick around for a while.

Note: This article was mostly there to allow my team to both evaluate themselves and to get up to speed with the way we use TypeScript’s highly expressive type definition syntax.

Disclaimer

The types defined in this article are not the best way to do it; They are mostly meant to illustrate the particular features of the TypeScript language.

How To Use This Article

It would be wise to use this article in order to evaluate yourself, and may pick up a few tricks along the way. Each section has a small little challenge, that you can attempt in the beautiful online TypeScript Playground on TypeScript’s own site.

To compare your solutions with mine, open the “Solution” collapsible at the end of each section. Once again you can and should strive to write even better types than this article.

Generics

Generics are basically just arguments for types.

Pass Through Function

This function just returns whatever is passed to it, as such the returned value should be the type of the passed in value.

// challenge: any is sin.
function passthrough(value: any): any {
return value;
}
// `apple` shuold be string, not any.
const apple = passthrough("apple");
// `bee` should be a number, not any.
const bee = passthrough(32);
With Proper Types
function passthrough<TYPE>(value: TYPE): TYPE {
return value;
}

Yes, TYPE is a generic.

You could also explicitly state the type when calling the function.

const a = passthrough<number>("potato"); // this will cause a type error.
const b = passthrough<string>("tomato"); // this won't.

Generics with Limits

What if we want a function that passes through only numbers and strings, but causes an erro for all other data types?

// challenge: allow only string and number as value but also copy the type onto return.
function passthrough(value: string | number): string | number {
return value;
}
// `apple` shuold be string, not string | number.
const apple = passthrough("apple");
// `bee` should be a number, not string | number.
const bee = passthrough(32);
// should cause a type error.
const cider = passthrough(true);
With Proper Types
function passthrough<TYPE extends string | number>(value: TYPE): TYPE {
return value;
}

Generic Interfaces

What if we want an interface that will have only string values or only number values, but never mixed?

// challenge: make this code DRY-er with Generics
interface OnlyStrings {
[key: string]: string;
}
type OnlyNumbers = {
[key: string]: number;
};
const data: OnlyStrings = {
a: "Apple",
};
const data: OnlyNumbers = {
a: 32,
};
With Better Types
interface Only<TYPE> {
[key: string]: TYPE;
};
const data: Only<string> = {
a: "Apple",
};
const data: Only<number> = {
a: 32,
};

Generics With type Keyword

// challenge: write this as a `type` declaration.
interface Car<TYPE> {
engine: TYPE;
}
With type Declaration
type Car<TYPE> = {
engine: TYPE;
};

Built-in Generics

Many types built-in into TypeScript are generics, for example. Arrays.

// challenge: Write `string[]` as a generic type's argument.
const names: string[] = [
"Morshed",
"Faisal",
"Farhad",
"John",
"Afia",
"Rifat",
];
With Generics
const names: Array<string> = ["Morshed", "Faisal", "Farhad", "John", "Afia", "Rifat"];

Multiple Generic arguments

Why stop at one?

// challenge: remove all the `any`s
function makeObject(a: any, b: any): { a: any; b: any } {
return { a, b };
}
With Generics
function makeObject<A_TYPE, B_TYPE>(a: A_TYPE, b: B_TYPE): {
a: A_TYPE,
b: B_TYPE
} {
return {a, b};
}

Generics Using Generics

// challenge: remove all the `any`s
function findIndex(array: any[], element: any): number | null {
const index = array.findIndex((candidate) => candidate === element);
return index < 0 ? null : index;
}
// this should work as expected.
const index01 = findIndex(["pico", "nano", "micro"], "micro");
// this should be a type error.
const index02 = findIndex(["pico", "nano", "micro"], false);
With Generics
function findIndex<ELEMENT, ARRAY extends Array<ELEMENT>>(array: ARRAY, element: ELEMENT): number | null {
const index = array.findIndex((candidate) => candidate === element);
return index < 0 ? null : index;
}

I know, I know, this could have been simpler, but just wanted to let you know, this also works.

Mapped Types

Function to Access Properties

// challenge: type this function properly
function getProperty(
object: {
[key: string]: any;
},
key: string
): any {
return object[key];
}
const obj = {
name: "Rakib Al Hasan",
age: 32,
};
// `name` shouhld be string.
const name = getProperty(obj, "name");
// should give type error.
const sex = getProperty(obj, "sex");
With Better Types
function getProperty<OBJECT extends {
[key: string]: any;
}, KEY extends keyof OBJECT>(object: OBJECT, key: KEY): OBJECT[KEY] {
return object[key];
}

This is also a good example of generics using generics.

Same Keys but Only Boolean Values

// challenge: Write the interface / type `Booleanize`.
type Original = {
name: string;
age: number;
is_married: boolean;
};
const booleanized: Booleanize<Original> = {
name: true,
age: 32, // should cause an error
is_married: false,
};
Solution
type Booleanize<OBJECT extends {
[key: string]: any
}> = {
[key in keyof OBJECT]: boolean;
};

Conditional Types

Type Flip-Flop

type StringBool = "yes" | "no";
// challenge: write the `Flip` type here.
// no type error.
const a: Flip<StringBool> = false;
// type error.
const b: Flip<boolean> = false;
// no type error
const c: Flip<boolean> = "yes";
// type error.
const d: Flip<StringBool> = "yes";
// type error.
const e: Flip<boolean> = "maybe";
Solution
type Flip<INPUT> = INPUT extends boolean ? StringBool : boolean;

Flip Value Types in Object

type Original = {
a: string;
b: number;
};
// challenge: write type `Flipped`
const flipped: Flipped<Original> = {
a: 2024, // should be: no error
b: 2023, // should be: error
};
Solution
type Flipped<ORIGINAL> = {
[key in keyof ORIGINAL]: ORIGINAL[key] extends number ? string : number;
};

Yes, key can be used to dereference a property’s type like in an actualy programming language.

The infer Keyword

So what if you want to extract the type a type arguments, say for example of a function that either takes a value or an array of values, and want to set the return type to just the type of the elements?

// challenge: properly type this function's signatures.
function firstValue(input: any[] | any): any | undefined {
return Array.isArray(input) ? input[0] : input;
}

Note: we have a union with undefined as the array could have a length of zero. We’ll cover ensuring, lengths of arrays in a later part of this article series.

Solution
function firstValue<INPUT extends Array<any> | any>(
input: INPUT
): INPUT extends Array<infer E> ? E | undefined : INPUT {
return Array.isArray(input) ? input[0] : input;
}

The infer keyword basically allows you to extract a generic type’s argument into it’s own generic type variable that you can use in other parts of your type definition.

Do note that the infer keyword only works within conditional types.

Next Steps

I’m already working on Part 2. Hopefully it should be complete sooner rather than later. Do stay tuned.