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`sfunction 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 properlyfunction 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 errorconst 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.