TypeScript — Confusing Concepts and Usage
A walkthrough of concepts such as Object literal, Generics, Index signature, etc. Consumed alongside the TypeScript Handbook
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.
- 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"
tostring
) - 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 T
s, and returns an array of T
s. 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 | nulltype 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 = stringtype 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 | number
and 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!