TypeScript — Confusing Concepts and Usage

A walkthrough of concepts such as Object literal, Generics, Index signature, etc. Consumed alongside the TypeScript Handbook

Photo by Darya Jumelya on Unsplash

Without further ado, let’s jump in! Note that some examples are extracted from the TypeScript Handbook, and all tributes should go to it.

Concepts that can be confusing

Object literal types

Object literal types are anonymous interfaces.

// explicit type
type Point = {
x: number;
y: number;
};
// anonymous function pointToString(pt: {x: number, y: number}) {
return `(${pt.x}, ${pt.y})`;
}

Type Aliases vs. Interface

They are very much the same, with the key distinction being that a type cannot be extended to add new properties while an interface which is extendable. Thus, a type cannot be changed after being created like below

type Window = {
title: string
}
type Window = {
ts: TypeScriptAPI
}
// Error: Duplicate identifier 'Window'.

Just to be safe, use interface until you need to use features from type.

Type Assertions

We can use as to tell TypeScript the information we are certain but TypeScript doesn’t know. Note that we can only have type assertions which convert to a more specific or less specific version of a type. Like a type annotation, type assertions are removed by the compiler at runtime.

For example, we can cast HTMLElement to a more specific HTMLCanvasElement . We can also use the angle-bracket syntax (except code in .tsx ).

const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");

Since the conversion is only allowed in the same time (more specific or more loose), so number cannot be casted as as “2” . For more flexibility, we can use two assertions, first to any ,then to the desired type:

const a = (expr as any) as T;

Literal Types & Literal Interface

Sometimes we need to type assert a generic value into a literal type. For example, in the req variable below, the type will be {url: string, method: string } . But we need it to be `“GET” | “POST” in handleRequest .

const req = { url: “https://example.com", method: “GET” };handleRequest(req.url, req.method);
// Argument of type ‘string’ is not assignable to parameter of type ‘“GET” | “POST”’.

There are two ways to work around this.

  1. You can change the inference by adding a type assertion in either location:
// Change 1:
const req = { url: "https://example.com", method: "GET" as "GET" };
// Change 2
handleRequest(req.url, req.method as "GET");

Change 1 means “I intend for req.method to always have the literal type "GET"”, preventing the possible assignment of "GUESS" to that field after. Change 2 means “I know for other reasons that req.method has the value "GET"“.

2. You can use as const to convert the entire object to be type literals:

const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);
// const req: {
// readonly url: "https://example.com";
// readonly method: "GET";
// }

The as const prefix acts like const but for the type system, ensuring that all properties are assigned the literal type instead of a more general version like string or number.

When we construct new literal expressions with const assertions, we can signal to the language that:

  • no literal types in that expression should be widened (e.g. no going from "hello" to string)
  • object literals get readonly properties
  • array literals become readonly tuples

Contextual typing

TypeScript is smart enough to infer the required type in the context, e.g. inside the body of a function:

const names = ["Alice", "Bob", "Eve"];names.forEach(function (s) {
console.log(s.toUppercase());
//Property 'toUppercase' does not exist on type 'string'.
});

Even though the parameter s didn’t have a type annotation, TypeScript used the types of the forEach function, along with the inferred type of the array, to determine the type s will have. This process is called contextual typing because the context that the function occurred in informed what type it should have.

Generics

Generics are all around type itself. The key motivation for generics is to use type parameters present type dependencies between members, in functions or in class.

Let’s say we want to have a function loggingIdentity to take a type parameter T, and an argument arg which is an array of Ts, and returns an array of Ts. But we don’t want to restrict this type to number or string.

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length);
return arg;
}

Note that we need to provide Array<Type> due to the contextual typing inside the function console.log(arg.length);

Generic Types & Generic Interface (Function)

The type of generic functions is just like those of non-generic functions, with the type parameters listed first, similarly to function declarations:

function identity<Type>(arg: Type): Type {
return arg;
}
// normal function declaration
let myIdentity: <Type>(arg: Type) => Type = identity;
// use different name for the generic type parameter in the type
let myIdentity: <Input>(arg: Input) => Input = identity;
// as a call signature of an object literal type
let myIdentity: { <Type>(arg: Type): Type } = identity;

We can derive Generic Interface from the normal Generic Type for better reusability :

interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn = identity;

We can go one step further, and move the generic parameter to be a parameter of the whole interface. This gives as more visibility on the type of the Generics is used on GenericIdentityFn<string> vs GenericIdentityFn . Note that we need to specify the specific type argument (here: number) when using GenericIdentityFn<string> , to lock in what the underlying call signature will use.

interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity;

Generic Class

class GenericNumber<T> {
static staticValue: string // cannot use T here
zeroValue: T;
add: (x: T, y: T) => T;
}
let myGenericNumber = new GenericNumber<number>();myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};

Since a class has two sides to its type: the static side and the instance side. Generic classes are only generic over their instance side , and static members can not use the class’s type parameter.

Generic Constraints

We can use constraint to limit the kinds of types that a type parameter can accept using an extends clause, <Type extends Lengthwise> Here Type can be deemed as an alias to Lengthwise. It has nothing to do with extending a type or inheritance, contrary to extending interfaces.

function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
//Property 'length' does not exist on type 'Type'
return arg;
}
===============>interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // OK
return arg;
}
loggingIdentity({ length: 10, value: 3 });

We can also constraint one Generic Type from another Generic Type using extends, such as <T, K extends keyof T> .

function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };getProperty(x, "a");
getProperty(x, "m"); //Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.

Guidelines for Good Generic Functions

  • Push type parameters down — when possible, use the type parameter itself rather than constraining it.
function filter1<Type>( arr: Type[]) {
return arr[0];
}
function filter2<Type extends any[]>(arr: Type) {
return arr[0];
}
// a: number[]
const a = filter1([1, 2, 3]);
// b : any[]
const b = filter2([1, 2, 3]);

In example above, filter2 inferred return type is any because TypeScript has to resolve the arr[0] expression using the constraint type, rather than “waiting” to resolve the element during a call.

  • Use fewer type parameters where possible
function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
return arr.filter(func);
}
function filter2<Type, Func extends (arg: Type) => boolean>(
arr: Type[],
func: Func

): Type[] {
return arr.filter(func);
}
  • Make type parameters should appear twice

Since type parameters are for describing relationship among the types of multiple values, it does not make sense to only use it in one place of the funtion:

function greet<Str extends string>(s: Str) {
console.log("Hello, " + s);
}
=====vs.
function greet<Str extends string>(s: Str): Str[] {
return [s, s, s]
}

Using Class Types in Generics

When creating factories in TypeScript using generics, it is necessary to refer to class types by their constructor functions. A more advanced example uses the prototype property to infer and constrain relationships between the constructor function and the instance of class types.

class BeeKeeper {
hasMask: boolean;
}
class ZooKeeper {
nametag: string;
}
class Animal {
numLegs: number;
}
class Bee extends Animal {
keeper: BeeKeeper;
}
class Lion extends Animal {
keeper: ZooKeeper;
}
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;

See more about this in the Mixin section below.

Generic Type Alias

We can use type alias to manipulate Generics even more and create new types from them:

type OrNull<Type> = Type | null;type OneOrMany<Type> = Type | Type[];type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;
// type OneOrManyOrNull = OneOrMany | null
type OneOrManyOrNullStrings = OneOrManyOrNull<string>;
// type OneOrManyOrNullStrings = OneOrMany | null

Keyof Typeof

keyof typeof is a common usage in TypeScript, but it can be daunting for anyone who first come across it.

keyof

keyof of some type T gives you a new type that is a union of literal types derived from the names of the properties of T. The resulting type is a subtype of string. It only works on the type level. You cannot apply it to a JavaScript value.

interface Person {
name: string
age: number
}
type PersonType = keyof Person //"name" | "age"

keyof typeof

typeof operator returns the type of an object. In the above example of Person , since we already knew the type, we just need keyof operator on type Person. But this comes in handy when we don’t know the type of an object.

const person = { name: "John", age: 15 }typeof person //{ name: string, age: number }type PersonType = keyof typeof person //"name" | "age"

keyof typeof on enum

In Typescript, enums are used as types at compile-time but they are compiled as objects at runtime in JavaScript. When we want to have properties of an enum just like that of an object, we can use keyof typeof .

enum ColorEnum {
white = '#ffffff',
black = '#000000',
}
type Colors = keyof typeof ColorsEnumlet colorExample: Colors
colorExample = "white"
colorExample = "black"
colorExample = "red" // Error...

Note that the reason we have to use typeof on Enum before applying keyof is because in TypeScript, some concepts are types and values at the same time:

  • classes,
  • enums,
  • namespaces.

When we deal with something that is a type and a value at the same time (like a class or an enum), we need typeof to cast the something into its type first. The example below shows Class in this case:

declare class Person{
static staticProperty: string;
dynamicProperty: string;
}
type Constructor = typeof Person;
type Instance = Person;
type A = keyof Constructor; // "prototype" | "staticProperty"
type B = keyof Instance; // "dynamicProperty"

Note that if the type has a string or number index signature, keyof will return those types instead. Note that M is string | number — this is because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"].

type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish; // type A: number
type Mapish = { [k: string]: boolean };
type B = keyof Mapish; // type B : string | number

Index signature

An index signature parameter type must be ‘string’ or ‘number’. And is useful for defining index (obvious?):

let person:{ [index:string] : {age: number} } = {};person[‘John’] = { age: 15 };
person[‘Beth’] = { age: ‘16’ }; //Error

Note that the name of the index signature e.g. index in { [index:string] : {age: number} } is not important, you can change it to anything you’d like.

Combining with union of literal strings and Mapped Types, an index signature can add constraints to a type using in . in is used when we want to type with a union of string, number or symbol literals.

type Index = 'a' | 'b' | 'c'
type FromIndex = { [k in Index]?: number }
const ok: FromIndex = {b:1, c:2}
const notOk: FromIndex = {b:1, c:2, d:3}; //Error

An extended usage is to use with keyof typeof and Generics together to capture vocabulary types as the specific type can be deferred generically:

type FromSomeIndex<K extends string> = { [key in K]: number }const fromIndex: FromSomeIndex<'a'> = { 'a': 2 }

Conditional Types

Conditional types looks like the conditional expressions (condition ? trueExpression : falseExpression) in JavaScript:

SomeType extends OtherType ? TrueType : FalseType;

The power of conditional types comes from using them with generics. Let’s see if we have a createLabel API interface that can take a label property (id or name) and and return a label object:

interface IdLabel {
id: number
}
interface NameLabel {
name: string
}
// use overloads
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
throw "unimplemented";
}

//use conditional type
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
}
let a = createLabel("typescript");// IdLabel
let b = createLabel(2.8);
let c = createLabel(Math.random() ? "hello" : 42);

Conditional Type Constraints

We can use conditional type with Generics together to further constrain the types.

type MessageOf<T> = T extends { message: unknown } 
? T["message"]
: never;
interface Email {
message: string;
}
interface Dog {
bark(): void;
}
type EmailMessageContents = MessageOf<Email>;
// type EmailMessageContents = string
type DogMessageContents = MessageOf<Dog>;
//type DogMessageContents = never

Distributive Conditional Types

When conditional types act on a generic type, they become distributive given a union type in the implemetation. In the following example, the StrOrNumArray distributes on string | numberand maps over each member type of the union:

type ToArray<Type> = Type extends any ? Type[] : never;type StrArrOrNumArr = ToArray<string | number>;
// type StrArrOrNumArr = string[] | number[]

Mapped Types

Mapped Types transforms existing types by iterating over the type properties. We will need in and keyof to achieve that.

interface Person {
name: string
age: number
}
interface SweetPerson { [key in keyof Person] : () => Type[key] }

There are two additional modifiers which can be applied during mapping: readonly and ? which affect mutability and optionality respectively. You can remove or add these modifiers by prefixing with - or +. If you don’t add a prefix, then + is assumed.

type CreateMutable<Type> = {
-readonly [Property in keyof Type]-?: Type[Property];
};
type LockedAccount = {
readonly id?: string;
readonly name?: string;
};
type UnlockedAccount = CreateMutable<LockedAccount>;
// type UnlockedAccount = {
// id: string;
// name: string;
// }

From TypeScript 4.1 onwards, we can re-map keys in mapped types with an as clause and leverage features like template literal types to create new property names from prior ones:

type Getters<Type> = {
[Property in keyof Type as `get${Capitalize<string & Property>}`]
: () => Type[Property]
};
interface Person {
name: string;
age: number;
}
type LazyPerson = Getters<Person>;
// type LazyPerson = {
// getName: () => string;
// getAge: () => number;
// }

Template Literal Types

Template literal types build on string literal types, and have the ability to expand into many strings via unions. A template literal can also create new string literal type by concatenating the string.

type World = "world";
type Greeting = `hello ${World}`

For each interpolated position in the template literal, the unions are cross multiplied:

type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
// ^ = type AllLocaleIDs = "welcome_email_id" | "email_heading_id" // | "footer_title_id" | "footer_sendoff_id"
type Lang = "en" | "ja" | "pt";type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`;
// ^ = type LocaleMessageIDs = "en_welcome_email_id" |
// "en_email_heading_id" | "en_footer_title_id" |
// "en_footer_sendoff_id" | "ja_welcome_email_id" | //"ja_email_heading_id" | "ja_footer_title_id" | //"ja_footer_sendoff_id" | "pt_welcome_email_id" | //"pt_email_heading_id" | "pt_footer_title_id" | //"pt_footer_sendoff_id"

That’s so much of it!

Happy Reading!