References
TypeScript's type system is very powerful because it allows expressing types in terms of other types.
The simplest form of this idea is generics, we actually have a wide variety of type operators available to use. It's also possible to express types in terms of values that we already have.
By combining various type operators, we can express complex operations and values in a succinct, maintainable way. In this section we'll cover ways to express a new type in terms of an existing type or value.
- Generics - Types which take parameters
- Keyof Type Operator - Using the
keyof
operator to create new types - Typeof Type Operator - Using the
typeof
operator to create new types - Indexed Access Types - Using
Type['a']
syntax to access a subset of a type - Conditional Types - Types which act like if statements in the type system
- Mapped Types - Creating types by mapping each property in an existing type
- Template Literal Types - Mapped types which change properties via template literal strings
References
A major part of software engineering is building components that not only have well-defined and consistent APIs, but are also reusable. Components that are capable of working on the data of today as well as the data of tomorrow will give you the most flexible capabilities for building up large software systems.
In languages like C# and Java, one of the main tools in the toolbox for creating reusable components is generics, that is, being able to create a component that can work over a variety of types rather than a single one. This allows users to consume these components and use their own types.
… skipped …
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
// Property 'length' does not exist on type 'Type'.
return arg;
}
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
……
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Type>(arg: Type) => Type = identity;
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: <Input>(arg: Input) => Input = identity;
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn = identity;
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity;
……
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
return x + y;
};
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));
……
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
}
……
You can declare a type parameter that is constrained by another type parameter.
For example, here we'd like to get a property from an object given its name.
We'd like to ensure that we're not accidentally grabbing a property that does not exist on the obj
, so we'll place a constraint between the two types:
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
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"'.
class BeeKeeper {
hasMask: boolean = true;
}
class ZooKeeper {
nametag: string = "Mikle";
}
class Animal {
numLegs: number = 4;
}
class Bee extends Animal {
keeper: BeeKeeper = new BeeKeeper();
}
class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;
……
References
The keyof
operator takes an object type and produces a string or numeric literal union of its keys.
The following type P is the same type as “x” | “y”:
type Point = { x: number; y: number };
type P = keyof Point;
// 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 };
type A = keyof Arrayish;
// type A = number
type Mapish = { [k: string]: boolean };
type M = keyof Mapish;
// type M = string | number
Note that in this example, 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"]
.
keyof
types become especially useful when combined with mapped types, which we'll learn more about later.
JavaScript already has a typeof
operator you can use in an expression context:
// Prints "string"
console.log(typeof "Hello world");
TypeScript adds a typeof
operator you can use in a type context to refer to the type of a variable or property:
let s = "hello";
let n: typeof s;
// let n: string
This isn't very useful for basic types, but combined with other type operators, you can use typeof
to conveniently express many patterns.
For an example, let's start by looking at the predefined type ReturnType<T>
.
It takes a function type and produces its return type:
type Predicate = (x: unknown) => boolean;
type K = ReturnType<Predicate>;
// type K = boolean
If we try to use ReturnType
on a function name, we see an instructive error:
function f() {
return { x: 10, y: 3 };
}
type P = ReturnType<f>;
// 'f' refers to a value, but is being used as a type here. Did you mean 'typeof f'?
Remember that values and types aren't the same thing.
To refer to the type that the value f has, we use typeof
:
function f() {
return { x: 10, y: 3 };
}
type P = ReturnType<typeof f>;
// type P = {
// x: number;
// y: number;
// }
TypeScript intentionally limits the sorts of expressions you can use typeof
on.
Specifically, it's only legal to use typeof
on identifiers (i.e. variable names) or their properties.
This helps avoid the confusing trap of writing code you think is executing, but isn't:
// Meant to use = ReturnType<typeof msgbox>
let shouldContinue: typeof msgbox("Are you sure you want to continue?");
// ',' expected.
We can use an indexed access type to look up a specific property on another type:
type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];
// type Age = number
The indexing type is itself a type, so we can use unions, keyof
, or other types entirely:
type I1 = Person["age" | "name"];
// type I1 = string | number
type I2 = Person[keyof Person];
// type I2 = string | number | boolean
type AliveOrName = "alive" | "name";
type I3 = Person[AliveOrName];
// type I3 = string | boolean
You'll even see an error if you try to index a property that doesn't exist:
type I1 = Person["alve"];
// Property 'alve' does not exist on type 'Person'.
Another example of indexing with an arbitrary type is using number
to get the type of an array's elements.
We can combine this with typeof
to conveniently capture the element type of an array literal:
const MyArray = [
{ name: "Alice", age: 15 },
{ name: "Bob", age: 23 },
{ name: "Eve", age: 38 },
];
type Person = typeof MyArray[number];
type Person = {
name: string;
age: number;
};
type Age = typeof MyArray[number]["age"];
type Age = number;
// Or
type Age2 = Person["age"];
type Age2 = number;
You can only use types when indexing, meaning you can't use a const to make a variable reference:
const key = "age";
type Age = Person[key];
// Type 'key' cannot be used as an index type.
// 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
However, you can use a type alias for a similar style of refactor:
type key = "age";
type Age = Person[key];
At the heart of most useful programs, we have to make decisions based on input. JavaScript programs are no different, but given the fact that values can be easily introspected, those decisions are also based on the types of the inputs. Conditional types help describe the relation between the types of inputs and outputs.
interface Animal {
live(): void;
}
interface Dog extends Animal {
woof(): void;
}
type Example1 = Dog extends Animal ? number : string;
// type Example1 = number
type Example2 = RegExp extends Animal ? number : string;
// type Example2 = string
Conditional types take a form that looks a little like conditional expressions (condition ? trueExpression : falseExpression
) in JavaScript:
SomeType extends OtherType ? TrueType : FalseType;
When the type on the left of the extends
is assignable to the one on the right, then you'll get the type in the first branch (the “true” branch); otherwise you'll get the type in the latter branch (the “false” branch).
From the examples above, conditional types might not immediately seem useful - we can tell ourselves whether or not Dog extends Animal
and pick number
or string
!
But the power of conditional types comes from using them with generics.
For example, let's take the following createLabel
function:
interface IdLabel {
id: number /* some fields */;
}
interface NameLabel {
name: string /* other fields */;
}
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";
}
These overloads for createLabel describe a single JavaScript function that makes a choice based on the types of its inputs. Note a few things:
- If a library has to make the same sort of choice over and over throughout its API, this becomes cumbersome.
- We have to create three overloads:
one for each case when we're sure of the type (one for
string
and one fornumber
), and one for the most general case (taking astring | number
). For every new typecreateLabel
can handle, the number of overloads grows exponentially.
Instead, we can encode that logic in a conditional type:
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
We can then use that conditional type to simplify our overloads down to a single function with no overloads.
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
}
let a = createLabel("typescript");
// let a: NameLabel
let b = createLabel(2.8);
// let b: IdLabel
let c = createLabel(Math.random() ? "hello" : 42);
// let c: NameLabel | IdLabel
Often, the checks in a conditional type will provide us with some new information. Just like with narrowing with type guards can give us a more specific type, the true branch of a conditional type will further constrain generics by the type we check against.
For example, let's take the following:
type MessageOf<T> = T["message"];
// Type '"message"' cannot be used to index type 'T'.
In this example, TypeScript errors because T
isn't known to have a property called message
.
We could constrain T
, and TypeScript would no longer complain:
type MessageOf<T extends { message: unknown }> = T["message"];
interface Email {
message: string;
}
type EmailMessageContents = MessageOf<Email>;
// type EmailMessageContents = string
However, what if we wanted MessageOf
to take any type, and default to something like never
if a message
property isn't available?
We can do this by moving the constraint out and introducing a conditional type:
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
Within the true branch, TypeScript knows that T
will have a message
property.
As another example, we could also write a type called Flatten
that flattens array types to their element types, but leaves them alone otherwise:
type Flatten<T> = T extends any[] ? T[number] : T;
// Extracts out the element type.
type Str = Flatten<string[]>;
// type Str = string
// Leaves the type alone.
type Num = Flatten<number>;
// type Num = number
When Flatten
is given an array type, it uses an indexed access with number to fetch out string[]
's element type.
Otherwise, it just returns the type it was given.
We just found ourselves using conditional types to apply constraints and then extract out types. This ends up being such a common operation that conditional types make it easier.
Conditional types provide us with a way to infer from types we compare against in the true branch using the infer
keyword.
For example, we could have inferred the element type in Flatten
instead of fetching it out “manually” with an indexed access type:
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
Here, we used the infer
keyword to declaratively introduce a new generic type variable named Item
instead of specifying how to retrieve the element type of T
within the true branch.
This frees us from having to think about how to dig through and probing apart the structure of the types we're interested in.
We can write some useful helper type aliases using the infer
keyword.
For example, for simple cases, we can extract the return type out from function types:
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
? Return
: never;
type Num = GetReturnType<() => number>;
// type Num = number
type Str = GetReturnType<(x: string) => string>;
// type Str = string
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
// type Bools = boolean[]
When inferring from a type with multiple call signatures (such as the type of an overloaded function), inferences are made from the last signature (which, presumably, is the most permissive catch-all case). It is not possible to perform overload resolution based on a list of argument types.
declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;
type T1 = ReturnType<typeof stringOrNum>;
// type T1 = string | number
When conditional types act on a generic type, they become distributive when given a union type. For example, take the following:
type ToArray<Type> = Type extends any ? Type[] : never;
If we plug a union type into ToArray
, then the conditional type will be applied to each member of that union.
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// type StrArrOrNumArr = string[] | number[]
What happens here is that StrArrOrNumArr
distributes on:
string | number;
and maps over each member type of the union, to what is effectively:
ToArray<string> | ToArray<number>;
which leaves us with:
string[] | number[];
Typically, distributivity is the desired behavior. To avoid that behavior, you can surround each side of the extends keyword with square brackets.
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
// 'StrArrOrNumArr' is no longer a union.
type StrArrOrNumArr = ToArrayNonDist<string | number>;
// type StrArrOrNumArr = (string | number)[]
When you don't want to repeat yourself, sometimes a type needs to be based on another type.
Mapped types build on the syntax for index signatures, which are used to declare the types of properties which have not been declared ahead of time:
type OnlyBoolsAndHorses = {
[key: string]: boolean | Horse;
};
const conforms: OnlyBoolsAndHorses = {
del: true,
rodney: false,
};
A mapped type is a generic type which uses a union of PropertyKeys (frequently created via a keyof) to iterate through keys to create a type:
todo oneday