In the last chapter I introduced the idea of type systems, but I never defined what the type in type system really means.
If that sounds confusing, let me give a few familiar examples:
The boolean
type is the set of all booleans (there are just two: true
and false
) and the operations you can perform on them (like ||
, &&
, and !
).
The number
type is the set of all numbers and the operations you can perform on them (like +
, -
, *, /
, %
, ||
, &&
, and ?
), including the methods you can call on them like .toFixed
, .toPrecision
, .toString
, and so on.
The string
type is the set of all strings and the operations you can perform on them (like +
, ||
, and &&
), including the methods you can call on them like .concat
and .toUpperCase
.
When you see that something is of type T
, not only do you know that it’s a T
, but you also know exactly what you can do with that T
(and what you can’t). Remember, the whole point is to use the typechecker to stop you from doing invalid things. And the way the typechecker knows what’s valid and what’s not is by looking at the types you’re using and how you’re using them.
In this chapter we’ll take a tour of the types available in TypeScript and cover the basics of what you can do with each of them. Figure 3-1 gives an overview.
When programmers talk about types, they share a precise, common vocabulary to describe what they mean. We’re going to use this vocabulary throughout this book.
Say you have a function that takes some value and returns that value multiplied by itself:
function
squareOf
(
n
)
{
return
n
*
n
}
squareOf
(
2
)
// evaluates to 4
squareOf
(
'z'
)
// evaluates to NaN
Clearly, this function will only work for numbers—if you pass anything besides a number to squareOf
, the result will be invalid. So what we do is explicitly annotate the parameter’s type:
function
squareOf
(
n
:
number
)
{
return
n
*
n
}
squareOf
(
2
)
// evaluates to 4
squareOf
(
'z'
)
// Error TS2345: Argument of type '"z"' is not assignable to
// parameter of type 'number'.
Now if we call squareOf
with anything but a number, TypeScript will know to complain right away. This is a trivial example (we’ll talk a lot more about functions in the next chapter), but it’s enough to introduce a couple of concepts that are key to talking about types in TypeScript. We can say the following things about the last code example:
squareOf
’s parameter n
is constrained to number
.
The type of the value 2
is assignable to (equivalently: compatible with) number
.
Without a type annotation, squareOf
is unconstrained in its parameter, and you can pass any type of argument to it. Once we constrain it, TypeScript goes to work for us verifying that every place we call our function, we call it with a compatible argument. In this example the type of 2
is number
, which is assignable to squareOf
’s annotation number
, so TypeScript accepts our code; but 'z'
is a string
, which is not assignable to number
, so TypeScript complains.
You can also think of it in terms of bounds: we told TypeScript that n
’s upper bound is number
, so any value we pass to squareOf
has to be at most a number
. If it’s anything more than a number
(like, if it’s a value that might be a number
or might be a string
), then it’s not assignable to n
.
I’ll define assignability, bounds, and constraints more formally in Chapter 6. For now, all you need to know is this is the language that we use to talk about whether or not a type can be used in a place where we require a certain type.
Let’s take a tour of the types TypeScript supports, what values they contain, and what you can do with them. We’ll also cover a few basic language features for working with types: type aliases, union types, and intersection types.
any
is the Godfather of types. It does anything for a price, but you don’t want to ask any
for a favor unless you’re completely out of options. In TypeScript everything needs to have a type at compile time, and any
is the default type when you (the programmer) and TypeScript (the typechecker) can’t figure out what type something is. It’s a last resort type, and you should avoid it when possible.
Why should you avoid it? Remember what a type is? (It’s a set of values and the things you can do with them.) any
is the set of all values, and you can do anything with any
. That means that if you have a value of type any
you can add to it, multiply by it, call .pizza()
on it—anything.
any
makes your value behave like it would in regular JavaScript, and totally prevents the typechecker from working its magic. When you allow any
into your code you’re flying blind. Avoid any
like fire, and use it only as a very, very last resort.
On the rare occasion that you do need to use it, you do it like this:
let
a
:any
=
666
// any
let
b
:any
=
[
'danger'
]
// any
let
c
=
a
+
b
// any
Notice how the third type should report an error (why are you trying to add a number and an array?), but doesn’t because you told TypeScript that you’re adding two any
s. If you want to use any
, you have to be explicit about it. When TypeScript infers that some value is of type any
(for example, if you forgot to annotate a function’s parameter, or if you imported an untyped JavaScript module), it will throw a compile-time exception and toss a red squiggly at you in your editor. By explicitly annotating a
and b
with the any
type (: any
), you avoid the exception—it’s your way of telling TypeScript that you know what you’re doing.
By default, TypeScript is permissive, and won’t complain about values that it infers as any
. To get TypeScript to complain about implicit any
s, be sure to enable the noImplicitAny
flag in your tsconfig.json.
noImplicitAny
is part of the strict
family of TSC flags, so if you already enabled strict
in your tsconfig.json (as we did in “tsconfig.json”), you’re good to go.
If any
is the Godfather, then unknown
is Keanu Reeves as undercover FBI agent Johnny Utah in Point Break: laid back, fits right in with the bad guys, but deep down has a respect for the law and is on the side of the good guys. For the few cases where you have a value whose type you really don’t know ahead of time, don’t use any
, and instead reach for unknown
. Like any
, it represents any value, but TypeScript won’t let you use an unknown
type until you refine it by checking what it is (see “Refinement”).
What operations does unknown
support? You can compare unknown
values (with ==
, ===
, ||
, &&
, and ?
), negate them (with !
), and refine them (like you can any other type) with JavaScript’s typeof
and instanceof
operators. Use unknown
like this:
let
a
:unknown
=
30
// unknown
let
b
=
a
===
123
// boolean
let
c
=
a
+
10
// Error TS2571: Object is of type 'unknown'.
if
(
typeof
a
===
'number'
)
{
let
d
=
a
+
10
// number
}
This example should give you a rough idea of how to use unknown
:
TypeScript will never infer something as unknown
—you have to explicitly annotate it (a
).1
You can compare values to values that are of type unknown
(b
).
But, you can’t do things that assume an unknown
value is of a specific type (c
); you have to prove to TypeScript that the value really is of that type first (d
).
The boolean
type has two values: true
and false
. You can compare them (with ==
, ===
, ||
, &&
, and ?
), negate them (with !
), and not much else. Use boolean
like this:
let
a
=
true
// boolean
var
b
=
false
// boolean
const
c
=
true
// true
let
d
:boolean
=
true
// boolean
let
e
:true
=
true
// true
let
f
:true
=
false
// Error TS2322: Type 'false' is not assignable
// to type 'true'.
This example shows a few ways to tell TypeScript that something is a boolean
:
You can let TypeScript infer that your value is a boolean
(a
and b
).
You can let TypeScript infer that your value is a specific boolean
(c
).
You can tell TypeScript explicitly that your value is a boolean
(d
).
You can tell TypeScript explicitly that your value is a specific boolean
(e
and f
).
In general, you will use the first or second way in your programs. Very rarely, you’ll use the fourth way—only when it buys you extra type safety (I’ll show you examples of that throughout this book). You will almost never use the third way.
The second and fourth cases are particularly interesting because while they do something intuitive, they’re supported by surprisingly few programming languages and so might be new to you. What I did in that example was say, “Hey TypeScript! See this variable e
here? e
isn’t just any old boolean
—it’s the specific boolean
true
.” By using a value as a type, I essentially limited the possible values for e
and f
from all booleans
to one specific boolean
each. This feature is called type literals.
In the fourth case I explicitly annotated my variables with type literals, and in the second case TypeScript inferred a literal type for me because I used const
instead of let
or var
. Because TypeScript knows that once a primitive is assigned with const
its value will never change, it infers the most narrow type it can for that variable. That’s why in the second case TypeScript inferred c
’s type as true
instead of as boolean
. To learn more about why TypeScript infers different types for let
and const
, jump ahead to “Type Widening”.
We will revisit type literals throughout this book. They are a powerful language feature that lets you squeeze out extra safety all over the place. Type literals make TypeScript unique in the language world and are something you should lord over your Java friends.
number
is the set of all numbers: integers, floats, positives, negatives, Infinity
, NaN
, and so on. Numbers can do, well, numbery things, like addition (+
), subtraction (-
), modulo (%
), and comparison (<
). Let’s look at a few examples:
let
a
=
1234
// number
var
b
=
Infinity
*
0.10
// number
const
c
=
5678
// 5678
let
d
=
a
<
b
// boolean
let
e
:number
=
100
// number
let
f
:26.218
=
26.218
// 26.218
let
g
:26.218
=
10
// Error TS2322: Type '10' is not assignable
// to type '26.218'.
Like in the boolean
example, there are four ways to type something as a number
:
You can let TypeScript infer that your value is a number
(a
and b
).
You can use const
so TypeScript infers that your value is a specific number
(c
).
You can tell TypeScript explicitly that your value is a number
(e
).
You can tell TypeScript explicitly that your value is a specific number
(f
and g
).
And just like with booleans
, you’re usually going to let TypeScript infer the type for you (the first way). Once in a while you’ll do some clever programming that requires your number’s type to be restricted to a specific value (the second or fourth way). There is no good reason to explicitly type something as a number
(the third way).
bigint
is a newcomer to JavaScript and TypeScript: it lets you work with large integers without running into rounding errors. While the number
type can only represent whole numbers up to 253, bigint
can represent integers bigger than that too. The bigint
type is the set of all BigInts, and supports things like addition (+
), subtraction (-
), multiplication (*), division (/
), and comparison (<
). Use it like this:
let
a
=
1234
n
// bigint
const
b
=
5678
n
// 5678n
var
c
=
a
+
b
// bigint
let
d
=
a
<
1235
// boolean
let
e
=
88.5
n
// Error TS1353: A bigint literal must be an integer.
let
f
:bigint
=
100
n
// bigint
let
g
:100n
=
100
n
// 100n
let
h
:bigint
=
100
// Error TS2322: Type '100' is not assignable
// to type 'bigint'.
Like with boolean
and number
, there are four ways to declare bigints. Try to let TypeScript infer your bigint’s type when you can.
At the time of writing, bigint
is not yet natively supported by every JavaScript engine. If your application relies on bigint
, be careful to check whether or not it’s supported by your target platform.
string
is the set of all strings and the things you can do with them like concatenate (+
), slice (.slice
), and so on. Let’s see some examples:
let
a
=
'hello'
// string
var
b
=
'billy'
// string
const
c
=
'!'
// '!'
let
d
=
a
+
' '
+
b
+
c
// string
let
e
:string
=
'zoom'
// string
let
f
:
'john'
=
'john'
// 'john'
let
g
:
'john'
=
'zoe'
// Error TS2322: Type "zoe" is not assignable
// to type "john".
Like boolean
and number
, there are four ways to declare string
types, and you should let TypeScript infer the type for you whenever you can.
symbol
is a relatively new language feature that arrived with one of the latest major JavaScript revisions (ES2015). Symbols don’t come up often in practice; they are used as an alternative to string keys in objects and maps, in places where you want to be extra sure that people are using the right well-known key and didn’t accidentally set the key—think setting a default iterator for your object (Symbol.iterator
), or overriding at runtime whether or not your object is an instance of something (Symbol.hasInstance
). Symbols have the type symbol
, and there isn’t all that much you can do with them:
let
a
=
Symbol
(
'a'
)
// symbol
let
b
:symbol
=
Symbol
(
'b'
)
// symbol
var
c
=
a
===
b
// boolean
let
d
=
a
+
'x'
// Error TS2469: The '+' operator cannot be applied
// to type 'symbol'.
The way Symbol('a')
works in JavaScript is by creating a new symbol
with the given name; that symbol
is unique, and will not be equal (when compared with ==
or ===
) to any other symbol
(even if you create a second symbol
with the same exact name!). Similarly to how the value 27
is inferred to be a number
when declared with let
but the specific number 27
when you declare it with const
, symbols are inferred to be of type symbol
but can be explicitly typed as unique symbol
:
const
e
=
Symbol
(
'e'
)
// typeof e
const
f
:unique
symbol
=
Symbol
(
'f'
)
// typeof f
let
g
:unique
symbol
=
Symbol
(
'f'
)
// Error TS1332: A variable whose type is a
// 'unique symbol' type must be 'const'.
let
h
=
e
===
e
// boolean
let
i
=
e
===
f
// Error TS2367: This condition will always return
// 'false' since the types 'unique symbol' and
// 'unique symbol' have no overlap.
This example shows off a few ways to create unique symbols:
When you declare a new symbol
and assign it to a const
variable (not a let
or var
variable), TypeScript will infer its type as unique symbol
. It will show up as typeof
yourVariableName, not unique symbol
, in your code editor.
You can explicitly annotate a const
variable’s type as unique symbol
.
A unique symbol
is always equal to itself.
TypeScript knows at compile time that a unique symbol
will never be equal to any other unique symbol
.
Think of unique symbols
like other literal types, like 1
, true
, or "literal"
. They’re a way to create a type that represents a particular inhabitant of symbol
.
TypeScript’s object types specify the shapes of objects. Notably, they can’t tell the difference between simple objects (like the kind you make with {}
) and more complicated ones (the kind you create with new Blah
). This is by design: JavaScript is generally structurally typed, so TypeScript favors that style of programming over a nominally typed style.
There are a few ways to use types to describe objects in TypeScript. The first is to declare a value as an object
:
let
a
:object
=
{
b
:
'x'
}
What happens when you access b
?
a
.
b
// Error TS2339: Property 'b' does not exist on type 'object'.
Wait, that’s not very useful! What’s the point of typing something as an object
if you can’t do anything with it?
Why, that’s a great point, aspiring TypeScripter! In fact, object
is a little narrower than any
, but not by much. object
doesn’t tell you a lot about the value it describes, just that the value is a JavaScript object (and that it’s not null
).
What if we leave off the explicit annotation, and let TypeScript do its thing?
let
a
=
{
b
:
'x'
}
// {b: string}
a
.
b
// string
let
b
=
{
c
:
{
d
:
'f'
}
}
// {c: {d: string}}
Voilà! You’ve just discovered the second way to type an object: object literal syntax (not to be confused with type literals). You can either let TypeScript infer your object’s shape for you, or explicitly describe it inside curly braces({}
):
let
a
:
{
b
:number
}
=
{
b
:12
}
// {b: number}
Object literal syntax says, “Here is a thing that has this shape.” The thing might be an object literal, or it might be a class:
let
c
:
{
firstName
:string
lastName
:string
}
=
{
firstName
:
'john'
,
lastName
:
'barrowman'
}
class
Person
{
constructor
(
public
firstName
:string
,
// public is shorthand for
// this.firstName = firstName
public
lastName
:string
)
{}
}
c
=
new
Person
(
'matt'
,
'smith'
)
// OK
{firstName: string, lastName: string}
describes the shape of an object, and both the object literal and the class instance from the last example satisfy that shape, so TypeScript lets us assign a Person
to c
.
Let’s explore what happens when we add extra properties, or leave out required ones:
let
a
:
{
b
:number
}
a
=
{}
// Error TS2741: Property 'b' is missing in type '{}'
// but required in type '{b: number}'.
a
=
{
b
:1
,
c
:2
// Error TS2322: Type '{b: number; c: number}' is not assignable
}
// to type '{b: number}'. Object literal may only specify known
// properties, and 'c' does not exist in type '{b: number}'.
By default, TypeScript is pretty strict about object properties—if you say the object should have a property called b
that’s a number
, TypeScript expects b
and only b
. If b
is missing, or if there are extra properties, TypeScript will complain.
Can you tell TypeScript that something is optional, or that there might be more properties than you planned for? You bet:
let
a
:
{
b
:
number
c?
:
string
[
key
:
number
]
:
boolean
}
a
has a property b
that’s a number
.
a
might have a property c
that’s a string
. And if c
is set, it might be undefined
.
a
might have any number of numeric properties that are booleans
.
Let’s see what types of objects we can assign to a
:
a
=
{
b
:1
}
a
=
{
b
:1
,
c
:undefined
}
a
=
{
b
:1
,
c
:
'd'
}
a
=
{
b
:1
,
10
:true
}
a
=
{
b
:1
,
10
:true
,
20
:false
}
a
=
{
10
:true
}
// Error TS2741: Property 'b' is missing in type
// '{10: true}'.
a
=
{
b
:1
,
33
:
'red'
}
// Error TS2741: Type 'string' is not assignable
// to type 'boolean'.
Optional (?
) isn’t the only modifier you can use when declaring object types. You can also mark fields as read-only (that is, you can declare that a field can’t be modified after it’s assigned an initial value—kind of like const
for object properties) with the readonly
modifier:
let
user
:
{
readonly
firstName
:string
}
=
{
firstName
:
'abby'
}
user
.
firstName
// string
user
.
firstName
=
'abbey with an e'
// Error TS2540: Cannot assign to 'firstName' because it
// is a read-only property.
Object literal notation has one special case: empty object types ({}
). Every type—except null
and undefined
—is assignable to an empty object type, which can make it tricky to use. Try to avoid empty object types when possible:
let
danger
:
{}
danger
=
{}
danger
=
{
x
:1
}
danger
=
[]
danger
=
2
As a final note on objects, it’s worth mentioning one last way of typing something as an object: Object
. This is pretty much the same as using {}
, and is best avoided. 3
To summarize, there are four ways to declare objects in TypeScript:
Object literal notation (like {a: string}
), also called a shape. Use this when you know which fields your object could have, or when all of your object’s values will have the same type.
Empty object literal notation ({}
). Try to avoid this.
The object
type. Use this when you just want an object, and don’t care about which fields it has.
The Object
type. Try to avoid this.
In your TypeScript programs, you should almost always stick to the first way and the third way. Be careful to avoid the second and fourth ways—use a linter to warn about them, complain about them in code reviews, print posters—use your team’s preferred tool to keep them far away from your codebase.
Table 3-1 is a handy reference for options 2–4 in the previous list.
Value | {} |
object |
Object |
---|---|---|---|
|
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
|
Yes |
No |
Yes |
|
Yes |
No |
Yes |
|
Yes |
No |
Yes |
|
No |
No |
No |
|
No |
No |
No |
You are quickly becoming a grizzled TypeScript programmer. You have seen several types and how they work, and are now familiar with the concepts of type systems, types, and safety. It’s time we go deeper.
As you know, if you have a value, you can perform certain operations on it, depending on what its type permits. For example, you can use +
to add two numbers, or .toUpperCase
to uppercase a string.
If you have a type, you can perform some operations on it too. I’m going to introduce a few type-level operations here—there are more to come later in the book, but these are so common that I want to introduce them as early as possible.
Just like you can use variable declarations (let
, const
, and var
) to declare a variable that aliases a value, you can declare a type alias that points to a type. It looks like this:
type
Age
=
number
type
Person
=
{
name
:string
age
:Age
}
Age
is but a number
. It can also help make the definition of the Person
shape easier to understand. Aliases are never inferred by TypeScript, so you have to type them explicitly:
let
age
:Age
=
55
let
driver
:Person
=
{
name
:
'James May'
age
:age
}
Because Age
is just an alias for number
, that means it’s also assignable to number
, so we can rewrite this as:
let
age
=
55
let
driver
:Person
=
{
name
:
'James May'
age
:age
}
Wherever you see a type alias used, you can substitute in the type it aliases without changing the meaning of your program.
Like JavaScript variable declarations (let
, const
, and var
), you can’t declare a type twice:
type
Color
=
'red'
type
Color
=
'blue'
// Error TS2300: Duplicate identifier 'Color'.
And like let
and const
, type aliases are block-scoped. Every block and every function has its own scope, and inner type alias declarations shadow outer ones:
type
Color
=
'red'
let
x
=
Math
.
random
()
<
.
5
if
(
x
)
{
type
Color
=
'blue'
// This shadows the Color declared above.
let
b
:Color
=
'blue'
}
else
{
let
c
:Color
=
'red'
}
Type aliases are useful for DRYing up repeated complex types,4 and for making it clear what a variable is used for (some people prefer descriptive type names to descriptive variable names!). When deciding whether or not to alias a type, use the same judgment as when deciding whether or not to pull a value out into its own variable.
If you have two things A
and B
, the union of those things is their sum (everything in A
or B
or both), and the intersection is what they have in common (everything in both A
and B
). The easiest way to think about this is with sets. In Figure 3-2 I represent sets as circles. On the left is the union, or sum, of the two sets; on the right is their intersection, or product.
TypeScript gives us special type operators to describe unions and intersections of types: |
for union and &
for intersection. Since types are a lot like sets, we can think of them in the same way:
type
Cat
=
{
name
:string
,
purrs
:boolean
}
type
Dog
=
{
name
:string
,
barks
:boolean
,
wags
:boolean
}
type
CatOrDogOrBoth
=
Cat
|
Dog
type
CatAndDog
=
Cat
&
Dog
If something is a CatOrDogOrBoth
, what do you know about it? You know that it has a name
property that’s a string, and not much else. On the flip side, what can you assign to a CatOrDogOrBoth
? Well, a Cat
, a Dog
, or both:
// Cat
let
a
:CatOrDogOrBoth
=
{
name
:
'Bonkers'
,
purrs
:true
}
// Dog
a
=
{
name
:
'Domino'
,
barks
:true
,
wags
:true
}
// Both
a
=
{
name
:
'Donkers'
,
barks
:true
,
purrs
:true
,
wags
:true
}
This is worth reiterating: a value with a union type (|
) isn’t necessarily one specific member of your union; in fact, it can be both members at once!5
On the other hand, what do you know about CatAndDog
? Not only does your canine-feline hybrid super-pet have a name
, but it can purr, bark, and wag:
let
b
:CatAndDog
=
{
name
:
'Domino'
,
barks
:true
,
purrs
:true
,
wags
:true
}
Unions come up naturally a lot more often than intersections do. Take this function, for example:
function
trueOrNull
(
isTrue
:boolean
)
{
if
(
isTrue
)
{
return
'true'
}
return
null
}
What is the type of the value this function returns? Well, it might be a string
, or it might be null
. We can express its return type as:
type
Returns
=
string
|
null
How about this one?
function
(
a
:string
,
b
:number
)
{
return
a
||
b
}
If a
is truthy then the return type is string
, and otherwise it’s number
: in other words, string | number
.
The last place where unions come up naturally is in arrays (specifically the heterogeneous kind), which we’ll talk about next.
Like in JavaScript, TypeScript arrays are special kinds of objects that support things like concatenation, pushing, searching, and slicing. It’s example time:
let
a
=
[
1
,
2
,
3
]
// number[]
var
b
=
[
'a'
,
'b'
]
// string[]
let
c
:string
[]
=
[
'a'
]
// string[]
let
d
=
[
1
,
'a'
]
// (string | number)[]
const
e
=
[
2
,
'b'
]
// (string | number)[]
let
f
=
[
'red'
]
f
.
push
(
'blue'
)
f
.
push
(
true
)
// Error TS2345: Argument of type 'true' is not
// assignable to parameter of type 'string'.
let
g
=
[]
// any[]
g
.
push
(
1
)
// number[]
g
.
push
(
'red'
)
// (string | number)[]
let
h
:number
[]
=
[]
// number[]
h
.
push
(
1
)
// number[]
h
.
push
(
'red'
)
// Error TS2345: Argument of type '"red"' is not
// assignable to parameter of type 'number'.
TypeScript supports two syntaxes for arrays: T[]
and Array<T>
. They are identical both in meaning and in performance. This book uses T[]
syntax for its terseness, but you should pick whichever style you like for your own code.
As you read through these examples, notice that everything but c
and h
is implicitly typed. You’ll also notice that TypeScript has rules about what you can and can’t put in an array.
The general rule of thumb is to keep arrays homogeneous. That is, don’t mix apples and oranges and numbers in a single array—try to design your programs so that every element of your array has the same type. The reason is that otherwise, you’re going to have to do more work to prove to TypeScript that what you’re doing is safe.
To see why things are easier when your arrays are homogeneous, take a look at example f
. I initialized an array with the string 'red'
(at the point when I declared the array it contained just strings, so TypeScript inferred that it must be an array of strings). I then pushed 'blue'
onto it; 'blue'
is a string, so TypeScript let it pass. Then I tried to push true
onto the array, but that failed! Why? Because f
is an array of strings, and true
is not a string.
On the other hand, when I initialized d
I gave it a number
and a string
, so TypeScript inferred that it must be an array of type number | string
. Because each element might be either a number or a string, you have to check which it is before using it. For example, say you want to map over that array, converting every letter to uppercase and tripling every number:
let
d
=
[
1
,
'a'
]
d
.
map
(
_
=>
{
if
(
typeof
_
===
'number'
)
{
return
_
*
3
}
return
_
.
toUpperCase
()
})
You have to query the type of each item with typeof
, checking if it’s a number
or a string
before you can do anything with it.
Like with objects, creating arrays with const
won’t hint to TypeScript to infer their types more narrowly. That’s why TypeScript inferred both d
and e
to be arrays of number | string
.
g
is the special case: when you initialize an empty array, TypeScript doesn’t know what type the array’s elements should be, so it gives you the benefit of the doubt and makes them any
. As you manipulate the array and add elements to it, TypeScript starts to piece together your array’s type. Once your array leaves the scope it was defined in (for example, if you declared it in a function, then returned it), TypeScript will assign it a final type that can’t be expanded anymore:
function
buildArray() {
let
a
=
[]
// any[]
a
.
push
(
1
)
// number[]
a
.
push
(
'x'
)
// (string | number)[]
return
a
}
let
myArray
=
buildArray
()
// (string | number)[]
myArray
.
push
(
true
)
// Error 2345: Argument of type 'true' is not
// assignable to parameter of type 'string | number'.
So as far as uses of any
go, this one shouldn’t make you sweat too much.
Tuples are subtypes of array
. They’re a special way to type arrays that have fixed lengths, where the values at each index have specific, known types. Unlike most other types, tuples have to be explicitly typed when you declare them. That’s because the JavaScript syntax is the same for tuples and arrays (both use square brackets), and TypeScript already has rules for inferring array types from square brackets:
let
a
:
[
number
]
=
[
1
]
// A tuple of [first name, last name, birth year]
let
b
:
[
string
,
string
,
number
]
=
[
'malcolm'
,
'gladwell'
,
1963
]
b
=
[
'queen'
,
'elizabeth'
,
'ii'
,
1926
]
// Error TS2322: Type 'string' is not
// assignable to type 'number'.
Tuples support optional elements too. Just like in object types, ?
means “optional”:
// An array of train fares, which sometimes vary depending on direction
let
trainFares
:
[
number
,
number
?
][]
=
[
[
3.75
],
[
8.25
,
7.70
],
[
10.50
]
]
// Equivalently:
let
moreTrainFares
:
([
number
]
|
[
number
,
number
])[]
=
[
// ...
]
Tuples also support rest elements, which you can use to type tuples with minimum lengths:
// A list of strings with at least 1 element
let
friends
:
[
string
,
...
string
[]]
=
[
'Sara'
,
'Tali'
,
'Chloe'
,
'Claire'
]
// A heterogeneous list
let
list
:
[
number
,
boolean
,
...
string
[]]
=
[
1
,
false
,
'a'
,
'b'
,
'c'
]
Not only do tuple types safely encode heterogeneous lists, but they also capture the length of the list they type. These features buy you significantly more safety than plain old arrays—use them often.
While regular arrays are mutable (meaning you can .push
onto them, .splice
them, and update them in place), which is probably what you want most of the time, sometimes you want an immutable array—one that you can update to produce a new array, leaving the original unchanged.
TypeScript comes with a readonly
array type out of the box, which you can use to create immutable arrays. Read-only arrays are just like regular arrays, but you can’t update them in place. To create a read-only array, use an explicit type annotation; to update a read-only array, use nonmutating methods like .concat
and .slice
instead of mutating ones like .push
and .splice
:
let
as
:
readonly
number
[]
=
[
1
,
2
,
3
]
// readonly number[]
let
bs
:readonly
number
[]
=
as
.
concat
(
4
)
// readonly number[]
let
three
=
bs
[
2
]
// number
as
[
4
]
=
5
// Error TS2542: Index signature in type
// 'readonly number[]' only permits reading.
as
.
push
(
6
)
// Error TS2339: Property 'push' does not
// exist on type 'readonly number[]'.
Like Array
, TypeScript comes with a couple of longer-form ways to declare read-only arrays and tuples:
type
A
=
readonly
string
[]
// readonly string[]
type
B
=
ReadonlyArray
<
string
>
// readonly string[]
type
C
=
Readonly
<
string
[]
>
// readonly string[]
type
D
=
readonly
[
number
,
string
]
// readonly [number, string]
type
E
=
Readonly
<
[
number
,
string
]
>
// readonly [number, string]
Which syntax you use—the terser readonly
modifier, or the longer-form Readonly
or ReadonlyArray
utilities—is a matter of taste.
Note that while read-only arrays can make your code easier to reason about in some cases by avoiding mutability, they are backed by regular JavaScript arrays. That means even small updates to an array result in having to copy the original array first, which can hurt your application’s runtime performance if you’re not careful. For small arrays this overhead is rarely noticeable, but for bigger arrays, the overhead can become significant.
If you plan to make heavy use of immutable arrays, consider reaching for a more efficient implementation, like Lee Byron’s excellent immutable
.
JavaScript has two values to represent an absence of something: null
and undefined
. TypeScript supports both of these as values, and it also has types for them—any guess what they’re called? You got it, the types are called null
and undefined
too.
They’re both special types, because in TypeScript the only thing of type undefined
is the value undefined
, and the only thing of type null
is the value null
.
JavaScript programmers usually use the two interchangeably, though there is a subtle semantic difference worth mentioning: undefined
means that something hasn’t been defined yet, and null
means an absence of a value (like if you tried to compute a value, but ran into an error along the way). These are just conventions and TypeScript doesn’t hold you to them, but it can be a useful distinction to make.
In addition to null
and undefined
, TypeScript also has void
and never
. These are really specific, special-purpose types that draw even finer lines between the different kinds of things that don’t exist: void
is the return type of a function that doesn’t explicitly return anything (for example, console.log
), and never
is the type of a function that never returns at all (like a function that throws an exception, or one that runs forever):
// (a) A function that returns a number or null
function
a
(
x
:number
)
{
if
(
x
<
10
)
{
return
x
}
return
null
}
// (b) A function that returns undefined
function
b() {
return
undefined
}
// (c) A function that returns void
function
c() {
let
a
=
2
+
2
let
b
=
a
*
a
}
// (d) A function that returns never
function
d() {
throw
TypeError
(
'I always error'
)
}
// (e) Another function that returns never
function
e() {
while
(
true
)
{
doSomething
()
}
}
(a) and (b) explicitly return null
and undefined
, respectively. (c) returns undefined
, but it doesn’t do so with an explicit return
statement, so we say it returns void
. (d) throws an exception, and (e) runs forever—neither will ever return, so we say their return type is never
.
If unknown
is the supertype of every other type, then never
is the subtype of every other type. We call it a bottom type. That means it’s assignable to every other type, and a value of type never
can be used anywhere safely. This has mostly theoretical significance,6 but is something that will come up when you talk about TypeScript with other language nerds.
Table 3-2 summarizes how the four absence types are used.
Type | Meaning |
---|---|
|
Absence of a value |
|
Variable that has not been assigned a value yet |
|
Function that doesn’t have a |
|
Function that never returns |
Enums are a way to enumerate the possible values for a type. They are unordered data structures that map keys to values. Think of them like objects where the keys are fixed at compile time, so TypeScript can check that the given key actually exists when you access it.
There are two kinds of enums: enums that map from strings to strings, and enums that map from strings to numbers. They look like this:
enum
Language
{
English
,
Spanish
,
Russian
}
TypeScript will automatically infer a number as the value for each member of your enum, but you can also set values explicitly. Let’s make explicit what TypeScript inferred in the previous example:
enum
Language
{
English
=
0
,
Spanish
=
1
,
Russian
=
2
}
To retrieve a value from an enum, you access it with either dot or bracket notation—just like you would to get a value from a regular object:
let
myFirstLanguage
=
Language
.
Russian
// Language
let
mySecondLanguage
=
Language
[
'English'
]
// Language
You can split your enum across multiple declarations, and TypeScript will automatically merge them for you (to learn more, jump ahead to “Declaration Merging”). Beware that when you do split your enum, TypeScript can only infer values for one of those declarations, so it’s good practice to explicitly assign a value to each enum member:
enum
Language
{
English
=
0
,
Spanish
=
1
}
enum
Language
{
Russian
=
2
}
You can use computed values, and you don’t have to define all of them (TypeScript will do its best to infer what’s missing):
enum
Language
{
English
=
100
,
Spanish
=
200
+
300
,
Russian
// TypeScript infers 501 (the next number after 500)
}
You can also use string values for enums, or even mix string and number values:
enum
Color
{
Red
=
'#c10000'
,
Blue
=
'#007ac1'
,
Pink
=
0xc10050
,
// A hexadecimal literal
White
=
255
// A decimal literal
}
let
red
=
Color
.
Red
// Color
let
pink
=
Color
.
Pink
// Color
TypeScript lets you access enums both by value and by key for convenience, but this can get unsafe quickly:
let
a
=
Color
.
Red
// Color
let
b
=
Color
.
Green
// Error TS2339: Property 'Green' does not exist
// on type 'typeof Color'.
let
c
=
Color
[
0
]
// string
let
d
=
Color
[
6
]
// string (!!!)
You shouldn’t be able to get Color[6]
, but TypeScript doesn’t stop you! We can ask TypeScript to prevent this kind of unsafe access by opting into a safer subset of enum behavior with const enum
instead. Let’s rewrite our Language
enum from earlier:
const
enum
Language
{
English
,
Spanish
,
Russian
}
// Accessing a valid enum key
let
a
=
Language
.
English
// Language
// Accessing an invalid enum key
let
b
=
Language
.
Tagalog
// Error TS2339: Property 'Tagalog' does not exist
// on type 'typeof Language'.
// Accessing a valid enum value
let
c
=
Language
[
0
]
// Error TS2476: A const enum member can only be
// accessed using a string literal.
// Accessing an invalid enum value
let
d
=
Language
[
6
]
// Error TS2476: A const enum member can only be
// accessed using a string literal.
A const enum
doesn’t let you do reverse lookups, and so behaves a lot like a regular JavaScript object. It also doesn’t generate any JavaScript code by default, and instead inlines the enum member’s value wherever it’s used (for example, TypeScript will replace every occurrence of Language.Spanish
with its value, 1
).
const enum
inlining can lead to safety issues when you import a const enum
from someone else’s TypeScript code: if the enum author updates their const enum
after you’ve compiled your TypeScript code, then your version of the enum and their version might point to different values at runtime, and TypeScript will be none the wiser.
If you use const enum
s, be careful to avoid inlining them and to only use them in TypeScript programs that you control: avoid using them in programs that you’re planning to publish to NPM, or to make available for others to use as a library.
To enable runtime code generation for const enum
s, switch the preserveConstEnums
TSC setting to true
in your tsconfig.json:
{
"compilerOptions"
:
{
"preserveConstEnums"
:
true
}
}
Let’s see how we use const enum
s:
const
enum
Flippable
{
Burger
,
Chair
,
Cup
,
Skateboard
,
Table
}
function
flip
(
f
:Flippable
)
{
return
'flipped it'
}
flip
(
Flippable
.
Chair
)
// 'flipped it'
flip
(
Flippable
.
Cup
)
// 'flipped it'
flip
(
12
)
// 'flipped it' (!!!)
Everything looks great—Chairs
and Cups
work exactly as you expect… until you realize that all numbers are also assignable to enums! That behavior is an unfortunate consequence of TypeScript’s assignability rules, and to fix it you have to be extra careful to only use string-valued enums:
const
enum
Flippable
{
Burger
=
'Burger'
,
Chair
=
'Chair'
,
Cup
=
'Cup'
,
Skateboard
=
'Skateboard'
,
Table
=
'Table'
}
function
flip
(
f
:Flippable
)
{
return
'flipped it'
}
flip
(
Flippable
.
Chair
)
// 'flipped it'
flip
(
Flippable
.
Cup
)
// 'flipped it'
flip
(
12
)
// Error TS2345: Argument of type '12' is not
// assignable to parameter of type 'Flippable'.
flip
(
'Hat'
)
// Error TS2345: Argument of type '"Hat"' is not
// assignable to parameter of type 'Flippable'.
All it takes is one pesky numeric value in your enum to make the whole enum unsafe.
Because of all the pitfalls that come with using enums safely, I recommend you stay away from them—there are plenty of better ways to express yourself in TypeScript.
And if a coworker insists on using enums and there’s nothing you can do to change their mind, be sure to ninja-merge a few TSLint rules while they’re out to warn about numeric values and non-const
enums.
In short, TypeScript comes with a bunch of built-in types. You can let TypeScript infer types for you from your values, or you can explicitly type your values. const
will infer more specific types, let
and var
more general ones. Most types have general and more specific counterparts, the latter subtypes of the former (see Table 3-3).
Type | Subtype |
---|---|
|
Boolean literal |
|
BigInt literal |
|
Number literal |
|
String literal |
|
|
|
Object literal |
Array |
Tuple |
|
For each of these values, what type will TypeScript infer?
let a = 1042
let b = 'apples and oranges'
const c = 'pineapples'
let d = [true, true, false]
let e = {type: 'ficus'}
let f = [1, false]
const g = [3]
let h = null
(try this out in your code editor, then jump ahead to “Type Widening” if the result surprises you!)Why does each of these throw the error it does?
let
i
:3
=
3
i
=
4
// Error TS2322: Type '4' is not assignable to type '3'.
let
j
=
[
1
,
2
,
3
]
j
.
push
(
4
)
j
.
push
(
'5'
)
// Error TS2345: Argument of type '"5"' is not
// assignable to parameter of type 'number'.
let
k
:never
=
4
// Error TSTS2322: Type '4' is not assignable
// to type 'never'.
let
l
:unknown
=
4
let
m
=
l
*
2
// Error TS2571: Object is of type 'unknown'.
1 Almost. When unknown
is part of a union type, the result of the union will be unknown
. You’ll read more about union types in “Union and intersection types”.
2 Objects in JavaScript use strings for keys; arrays are special kinds of objects that use numerical keys.
3 There’s one minor technical difference: {}
lets you define whatever types you want for built-in methods on the Object
prototype, like .toString
and .hasOwnProperty
(head over to MDN to learn more about prototypes), while Object
enforces that the types you declare are assignable to those on Object
’s prototype. For example, this code typechecks: let a: {} = {toString() { return 3 }}
. But if you change the type annotation to Object
, TypeScript complains: let b: Object = {toString() { return 3 }}
results in Error TS2322: Type 'number' is not assignable to type 'string'
.
4 The acronym DRY stands for “Don’t Repeat Yourself”—the idea that code shouldn’t be repetitive. It was introduced by Andrew Hunt and David Thomas in their book The Pragmatic Programmer: From Journeyman to Master (Addison-Wesley).
5 Jump ahead to “Discriminated union types” to learn how to hint to TypeScript that your union is disjoint and a value of that union’s type has to be one or the other, and not both.
6 The way to think about a bottom type is as a type that has no values. A bottom type corresponds to a mathematical proposition that’s always false.
3.12.71.237