Chapter 4. Type Design
Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won’t usually need your flowcharts; they’ll be obvious.
Fred Brooks, The Mythical Man Month
The language in Fred Brooks’s quote is dated, but the sentiment remains true: code is difficult to understand if you can’t see the data or data types on which it operates. This is one of the great advantages of a type system: by writing out types, you make them visible to readers of your code. And this makes your code understandable.
Other chapters cover the nuts and bolts of TypeScript types: using them, inferring them, and writing declarations with them. This chapter discusses the design of the types themselves. The examples in this chapter are all written with TypeScript in mind, but most of the ideas are more broadly applicable.
If you write your types well, then with any luck your flowcharts will be obvious, too.
Item 28: Prefer Types That Always Represent Valid States
If you design your types well, your code should be straightforward to write. But if you design your types poorly, no amount of cleverness or documentation will save you. Your code will be confusing and bug prone.
A key to effective type design is crafting types that can only represent a valid state. This item walks through a few examples of how this can go wrong and shows you how to fix them.
Suppose you’re building a web application that lets you select a page, loads the content of that page, and then displays it. You might write the state like this:
interface
State
{
pageText
:string
;
isLoading
:boolean
;
error?
:string
;
}
When you write your code to render the page, you need to consider all of these fields:
function
renderPage
(
state
:State
)
{
if
(
state
.
error
)
{
return
`Error! Unable to load
${
currentPage
}
:
${
state
.
error
}
`
;
}
else
if
(
state
.
isLoading
)
{
return
`Loading
${
currentPage
}
...`
;
}
return
`<h1>
${
currentPage
}
</h1>
\
n
${
state
.
pageText
}
`
;
}
Is this right, though? What if isLoading
and error
are both set? What would that mean? Is it better to display the loading message or the error message? It’s hard to say! There’s not enough information available.
Or what if you’re writing a changePage
function? Here’s an attempt:
async
function
changePage
(
state
:State
,
newPage
:string
)
{
state
.
isLoading
=
true
;
try
{
const
response
=
await
fetch
(
getUrlForPage
(
newPage
));
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Unable to load
${
newPage
}
:
${
response
.
statusText
}
`
);
}
const
text
=
await
response
.
text
();
state
.
isLoading
=
false
;
state
.
pageText
=
text
;
}
catch
(
e
)
{
state
.
error
=
''
+
e
;
}
}
There are many problems with this! Here are a few:
-
We forgot to set
state.isLoading
tofalse
in the error case. -
We didn’t clear out
state.error
, so if the previous request failed, then you’ll keep seeing that error message instead of a loading message. -
If the user changes pages again while the page is loading, who knows what will happen. They might see a new page and then an error, or the first page and not the second depending on the order in which the responses come back.
The problem is that the state includes both too little information (which request failed? which is loading?) and too much: the State
type allows both isLoading
and error
to be set, even though this represents an invalid state. This makes both render()
and changePage()
impossible to implement well.
Here’s a better way to represent the application state:
interface
RequestPending
{
state
:
'pending'
;
}
interface
RequestError
{
state
:
'error'
;
error
:string
;
}
interface
RequestSuccess
{
state
:
'ok'
;
pageText
:string
;
}
type
RequestState
=
RequestPending
|
RequestError
|
RequestSuccess
;
interface
State
{
currentPage
:string
;
requests
:
{[
page
:string
]
:
RequestState
};
}
This uses a tagged union (also known as a “discriminated union”) to explicitly model the different states that a network request can be in. This version of the state is three to four times longer, but it has the enormous advantage of not admitting invalid states. The current page is modeled explicitly, as is the state of every request that you issue. As a result, the renderPage
and changePage
functions are easy to implement:
function
renderPage
(
state
:State
)
{
const
{
currentPage
}
=
state
;
const
requestState
=
state
.
requests
[
currentPage
];
switch
(
requestState
.
state
)
{
case
'pending'
:
return
`Loading
${
currentPage
}
...`
;
case
'error'
:
return
`Error! Unable to load
${
currentPage
}
:
${
requestState
.
error
}
`
;
case
'ok'
:
return
`<h1>
${
currentPage
}
</h1>
\
n
${
requestState
.
pageText
}
`
;
}
}
async
function
changePage
(
state
:State
,
newPage
:string
)
{
state
.
requests
[
newPage
]
=
{
state
:
'pending'
};
state
.
currentPage
=
newPage
;
try
{
const
response
=
await
fetch
(
getUrlForPage
(
newPage
));
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Unable to load
${
newPage
}
:
${
response
.
statusText
}
`
);
}
const
pageText
=
await
response
.
text
();
state
.
requests
[
newPage
]
=
{
state
:
'ok'
,
pageText
};
}
catch
(
e
)
{
state
.
requests
[
newPage
]
=
{
state
:
'error'
,
error
:
''
+
e
};
}
}
The ambiguity from the first implementation is entirely gone: it’s clear what the current page is, and every request is in exactly one state. If the user changes the page after a request has been issued, that’s no problem either. The old request still completes, but it doesn’t affect the UI.
For a simpler but more dire example, consider the fate of Air France Flight 447, an Airbus 330 that disappeared over the Atlantic on June 1, 2009. The Airbus was a fly-by-wire aircraft, meaning that the pilots’ control inputs went through a computer system before affecting the physical control surfaces of the plane. In the wake of the crash there were many questions raised about the wisdom of relying on computers to make such life-and-death decisions. Two years later when the black box recorders were recovered, they revealed many factors that led to the crash. But a key one was bad state design.
The cockpit of the Airbus 330 had a separate set of controls for the pilot and copilot. The “side sticks” controlled the angle of attack. Pulling back would send the airplane into a climb, while pushing forward would make it dive. The Airbus 330 used a system called “dual input” mode, which let the two side sticks move independently. Here’s how you might model its state in TypeScript:
interface
CockpitControls
{
/** Angle of the left side stick in degrees, 0 = neutral, + = forward */
leftSideStick
:number
;
/** Angle of the right side stick in degrees, 0 = neutral, + = forward */
rightSideStick
:number
;
}
Suppose you were given this data structure and asked to write a getStickSetting
function that computed the current stick setting. How would you do it?
One way would be to assume that the pilot (who sits on the left) is in control:
function
getStickSetting
(
controls
:CockpitControls
)
{
return
controls
.
leftSideStick
;
}
But what if the copilot has taken control? Maybe you should use whichever stick is away from zero:
function
getStickSetting
(
controls
:CockpitControls
)
{
const
{
leftSideStick
,
rightSideStick
}
=
controls
;
if
(
leftSideStick
===
0
)
{
return
rightSideStick
;
}
return
leftSideStick
;
}
But there’s a problem with this implementation: we can only be confident returning the left setting if the right one is neutral. So you should check for that:
function
getStickSetting
(
controls
:CockpitControls
)
{
const
{
leftSideStick
,
rightSideStick
}
=
controls
;
if
(
leftSideStick
===
0
)
{
return
rightSideStick
;
}
else
if
(
rightSideStick
===
0
)
{
return
leftSideStick
;
}
// ???
}
What do you do if they’re both non-zero? Hopefully they’re about the same, in which case you could just average them:
function
getStickSetting
(
controls
:CockpitControls
)
{
const
{
leftSideStick
,
rightSideStick
}
=
controls
;
if
(
leftSideStick
===
0
)
{
return
rightSideStick
;
}
else
if
(
rightSideStick
===
0
)
{
return
leftSideStick
;
}
if
(
Math
.
abs
(
leftSideStick
-
rightSideStick
)
<
5
)
{
return
(
leftSideStick
+
rightSideStick
)
/
2
;
}
// ???
}
But what if they’re not? Can you throw an error? Not really: the ailerons need to be set at some angle!
On Air France 447, the copilot silently pulled back on his side stick as the plane entered a storm. It gained altitude but eventually lost speed and entered a stall, a condition in which the plane is moving too slowly to effectively generate lift. It began to drop.
To escape a stall, pilots are trained to push the controls forward to make the plane dive and regain speed. This is exactly what the pilot did. But the copilot was still silently pulling back on his side stick. And the Airbus function looked like this:
function
getStickSetting
(
controls
:CockpitControls
)
{
return
(
controls
.
leftSideStick
+
controls
.
rightSideStick
)
/
2
;
}
Even though the pilot pushed the stick fully forward, it averaged out to nothing. He had no idea why the plane wasn’t diving. By the time the copilot revealed what he’d done, the plane had lost too much altitude to recover and it crashed into the ocean, killing all 228 people on board.
The point of all this is that there is no good way to implement getStickSetting
given that input! The function has been set up to fail. In most planes the two sets of controls are mechanically connected. If the copilot pulls back, the pilot’s controls will also pull back. The state of these controls is simple to express:
interface
CockpitControls
{
/** Angle of the stick in degrees, 0 = neutral, + = forward */
stickAngle
:number
;
}
And now, as in the Fred Brooks quote from the start of the chapter, our flowcharts are obvious. You don’t need a getStickSetting
function at all.
As you design your types, take care to think about which values you are including and which you are excluding. If you only allow values that represent valid states, your code will be easier to write and TypeScript will have an easier time checking it. This is a very general principle, and several of the other items in this chapter will cover specific manifestations of it.
Item 29: Be Liberal in What You Accept and Strict in What You Produce
This idea is known as the robustness principle or Postel’s Law, after Jon Postel, who wrote it in the context of TCP:
TCP implementations should follow a general principle of robustness: be conservative in what you do, be liberal in what you accept from others.
A similar rule applies to the contracts for functions. It’s fine for your functions to be broad in what they accept as inputs, but they should generally be more specific in what they produce as outputs.
As an example, a 3D mapping API might provide a way to position the camera and to calculate a viewport for a bounding box:
declare
function
setCamera
(
camera
:CameraOptions
)
:
void
;
declare
function
viewportForBounds
(
bounds
:LngLatBounds
)
:
CameraOptions
;
It is convenient that the result of viewportForBounds
can be passed directly to setCamera
to position the camera.
Let’s look at the definitions of these types:
interface
CameraOptions
{
center?
:LngLat
;
zoom?
:number
;
bearing?
:number
;
pitch?
:number
;
}
type
LngLat
=
{
lng
:number
;
lat
:number
;
}
|
{
lon
:number
;
lat
:number
;
}
|
[
number
,
number
];
The fields in CameraOptions
are all optional because you might want to set just the center or zoom without changing the bearing or pitch. The LngLat
type also makes setCamera
liberal in what it accepts: you can pass in a {lng, lat}
object, a {lon, lat}
object, or a [lng, lat]
pair if you’re confident you got the order right. These accommodations make the function easy to call.
The viewportForBounds
function takes in another “liberal” type:
type
LngLatBounds
=
{
northeast
:LngLat
,
southwest
:LngLat
}
|
[
LngLat
,
LngLat
]
|
[
number
,
number
,
number
,
number
];
You can specify the bounds either using named corners, a pair of lat/lngs, or a four-tuple if you’re confident you got the order right. Since LngLat
already accommodates three forms, there are no fewer than 19 possible forms for LngLatBounds
. Liberal indeed!
Now let’s write a function that adjusts the viewport to accommodate a GeoJSON Feature and stores the new viewport in the URL (for a definition of calculateBoundingBox
, see Item 31):
function
focusOnFeature
(
f
:Feature
)
{
const
bounds
=
calculateBoundingBox
(
f
);
const
camera
=
viewportForBounds
(
bounds
);
setCamera
(
camera
);
const
{
center
:
{
lat
,
lng
},
zoom
}
=
camera
;
// ~~~ Property 'lat' does not exist on type ...
// ~~~ Property 'lng' does not exist on type ...
zoom
;
// Type is number | undefined
window
.
location
.
search
=
`?v=@
${
lat
}
,
${
lng
}
z
${
zoom
}
`
;
}
Whoops! Only the zoom
property exists, but its type is inferred as number|undefined
, which is also problematic. The issue is that the type declaration for viewportForBounds
indicates that it is liberal not just in what it accepts but also in what it produces. The only type-safe way to use the camera
result is to introduce a code branch for each component of the union type (Item 22).
The return type with lots of optional properties and union types makes viewportForBounds
difficult to use. Its broad parameter type is convenient, but its broad return type is not. A more convenient API would be strict in what it produces.
One way to do this is to distinguish a canonical format for coordinates. Following JavaScript’s convention of distinguishing “Array” and “Array-like” (Item 16), you can draw a distinction between LngLat
and LngLatLike
. You can also distinguish between a fully defined Camera
type and the partial version accepted by setCamera
:
interface
LngLat
{
lng
:number
;
lat
:number
;
};
type
LngLatLike
=
LngLat
|
{
lon
:number
;
lat
:number
;
}
|
[
number
,
number
];
interface
Camera
{
center
:LngLat
;
zoom
:number
;
bearing
:number
;
pitch
:number
;
}
interface
CameraOptions
extends
Omit
<
Partial
<
Camera
>
,
'center'
>
{
center?
:LngLatLike
;
}
type
LngLatBounds
=
{
northeast
:LngLatLike
,
southwest
:LngLatLike
}
|
[
LngLatLike
,
LngLatLike
]
|
[
number
,
number
,
number
,
number
];
declare
function
setCamera
(
camera
:CameraOptions
)
:
void
;
declare
function
viewportForBounds
(
bounds
:LngLatBounds
)
:
Camera
;
The loose CameraOptions
type adapts the stricter Camera
type (Item 14).
Using Partial<Camera>
as the parameter type in setCamera
would not work here since you do want to allow LngLatLike
objects for the center
property. And you can’t write "CameraOptions extends Partial<Camera>
" since LngLatLike
is a superset of LngLat
, not a subset (Item 7). If this seems too complicated, you could also write the type out explicitly at the cost of some repetition:
interface
CameraOptions
{
center?
:LngLatLike
;
zoom?
:number
;
bearing?
:number
;
pitch?
:number
;
}
In either case, with these new type declarations the focusOnFeature
function passes the type checker:
function
focusOnFeature
(
f
:Feature
)
{
const
bounds
=
calculateBoundingBox
(
f
);
const
camera
=
viewportForBounds
(
bounds
);
setCamera
(
camera
);
const
{
center
:
{
lat
,
lng
},
zoom
}
=
camera
;
// OK
zoom
;
// Type is number
window
.
location
.
search
=
`?v=@
${
lat
}
,
${
lng
}
z
${
zoom
}
`
;
}
This time the type of zoom
is number
, rather than number|undefined
. The viewportForBounds
function is now much easier to use. If there were any other functions that produced bounds, you would also need to introduce a canonical form and a distinction between LngLatBounds
and LngLatBoundsLike
.
Is allowing 19 possible forms of bounding box a good design? Perhaps not. But if you’re writing type declarations for a library that does this, you need to model its behavior. Just don’t have 19 return types!
Item 30: Don’t Repeat Type Information in Documentation
/**
* Returns a string with the foreground color.
* Takes zero or one arguments. With no arguments, returns the
* standard foreground color. With one argument, returns the foreground color
* for a particular page.
*/
function
getForegroundColor
(
page?
:string
)
{
return
page
===
'login'
?
{
r
:127
,
g
:127
,
b
:127
}
:
{
r
:0
,
g
:0
,
b
:0
};
}
The code and the comment disagree! Without more context it’s hard to say which is right, but something is clearly amiss. As a professor of mine used to say, “when your code and your comments disagree, they’re both wrong!”
Let’s assume that the code represents the desired behavior. There are a few issues with this comment:
-
It says that the function returns the color as a
string
when it actually returns an{r, g, b}
object. -
It explains that the function takes zero or one arguments, which is already clear from the type signature.
-
It’s needlessly wordy: the comment is longer than the function declaration and implementation!
TypeScript’s type annotation system is designed to be compact, descriptive, and readable. Its developers are language experts with decades of experience. It’s almost certainly a better way to express the types of your function’s inputs and outputs than your prose!
And because your type annotations are checked by the TypeScript compiler, they’ll never get out of sync with the implementation. Perhaps getForegroundColor
used to return a string but was later changed to return an object. The person who made the change might have forgotten to update the long comment.
Nothing stays in sync unless it’s forced to. With type annotations, TypeScript’s type checker is that force! If you put type information in annotations and not in documentation, you greatly increase your confidence that it will remain correct as the code evolves.
A better comment might look like this:
/** Get the foreground color for the application or a specific page. */
function
getForegroundColor
(
page?
:string
)
:
Color
{
// ...
}
If you want to describe a particular parameter, use an @param
JSDoc annotation. See Item 48 for more on this.
Comments about a lack of mutation are also suspect. Don’t just say that you don’t modify a parameter:
/** Does not modify nums */
function
sort
(
nums
:number
[])
{
/* ... */
}
Instead, declare it readonly
(Item 17) and let TypeScript enforce the contract:
function
sort
(
nums
:readonly
number
[])
{
/* ... */
}
What’s true for comments is also true for variable names. Avoid putting types in them: rather than naming a variable ageNum
, name it age
and make sure it’s really a number
.
An exception to this is for numbers with units. If it’s not clear what the units are, you may want to include them in a variable or property name. For instance, timeMs
is a much clearer name than just time
, and temperatureC
is a much clearer name than temperature
. Item 37 describes “brands,” which provide a more type-safe approach to modeling units.
Things to Remember
-
Avoid repeating type information in comments and variable names. In the best case it is duplicative of type declarations, and in the worst it will lead to conflicting information.
-
Consider including units in variable names if they aren’t clear from the type (e.g.,
timeMs
ortemperatureC
).
Item 31: Push Null Values to the Perimeter of Your Types
When you first turn on strictNullChecks
, it may seem as though you have to add scores of if statements checking for null
and undefined
values throughout your code. This is often because the relationships between null and non-null values are implicit: when variable A is non-null, you know that variable B is also non-null and vice versa. These implicit relationships are confusing both for human readers of your code and for the type checker.
Values are easier to work with when they’re either completely null or completely non-null, rather than a mix. You can model this by pushing the null values out to the perimeter of your structures.
Suppose you want to calculate the min and max of a list of numbers. We’ll call this the “extent.” Here’s an attempt:
function
extent
(
nums
:number
[])
{
let
min
,
max
;
for
(
const
num
of
nums
)
{
if
(
!
min
)
{
min
=
num
;
max
=
num
;
}
else
{
min
=
Math
.
min
(
min
,
num
);
max
=
Math
.
max
(
max
,
num
);
}
}
return
[
min
,
max
];
}
The code type checks (without strictNullChecks
) and has an inferred return type of number[]
, which seems fine. But it has a bug and a design flaw:
-
If the min or max is zero, it may get overridden. For example,
extent([0, 1, 2])
will return[1, 2]
rather than[0, 2]
. -
If the
nums
array is empty, the function will return[undefined, undefined]
. This sort of object with severalundefined
s will be difficult for clients to work with and is exactly the sort of type that this item discourages. We know from reading the source code thatmin
andmax
will either both beundefined
or neither, but that information isn’t represented in the type system.
Turning on strictNullChecks
makes both of these issues more apparent:
function
extent
(
nums
:number
[])
{
let
min
,
max
;
for
(
const
num
of
nums
)
{
if
(
!
min
)
{
min
=
num
;
max
=
num
;
}
else
{
min
=
Math
.
min
(
min
,
num
);
max
=
Math
.
max
(
max
,
num
);
// ~~~ Argument of type 'number | undefined' is not
// assignable to parameter of type 'number'
}
}
return
[
min
,
max
];
}
The return type of extent
is now inferred as (number | undefined)[]
, which makes the design flaw more apparent. This is likely to manifest as a type error wherever you call extent
:
const
[
min
,
max
]
=
extent
([
0
,
1
,
2
]);
const
span
=
max
-
min
;
// ~~~ ~~~ Object is possibly 'undefined'
The error in the implementation of extent
comes about because you’ve excluded undefined
as a value for min
but not max
. The two are initialized together, but this information isn’t present in the type system. You could make it go away by adding a check for max
, too, but this would be doubling down on the bug.
A better solution is to put the min and max in the same object and make this object either fully null
or fully non-null
:
function
extent
(
nums
:number
[])
{
let
result
:
[
number
,
number
]
|
null
=
null
;
for
(
const
num
of
nums
)
{
if
(
!
result
)
{
result
=
[
num
,
num
];
}
else
{
result
=
[
Math
.
min
(
num
,
result
[
0
]),
Math
.
max
(
num
,
result
[
1
])];
}
}
return
result
;
}
The return type is now [number, number] | null
, which is easier for clients to work with. The min and max can be retrieved with either a non-null assertion:
const
[
min
,
max
]
=
extent
([
0
,
1
,
2
])
!
;
const
span
=
max
-
min
;
// OK
or a single check:
const
range
=
extent
([
0
,
1
,
2
]);
if
(
range
)
{
const
[
min
,
max
]
=
range
;
const
span
=
max
-
min
;
// OK
}
By using a single object to track the extent, we’ve improved our design, helped TypeScript understand the relationship between null values, and fixed the bug: the if (!result)
check is now problem free.
A mix of null and non-null values can also lead to problems in classes. For instance, suppose you have a class that represents both a user and their posts on a forum:
class
UserPosts
{
user
:UserInfo
|
null
;
posts
:Post
[]
|
null
;
constructor
()
{
this
.
user
=
null
;
this
.
posts
=
null
;
}
async
init
(
userId
:string
)
{
return
Promise
.
all
([
async
()
=>
this
.
user
=
await
fetchUser
(
userId
),
async
()
=>
this
.
posts
=
await
fetchPostsForUser
(
userId
)
]);
}
getUserName() {
// ...?
}
}
While the two network requests are loading, the user
and posts
properties will be null
. At any time, they might both be null
, one might be null
, or they might both be non-null
. There are four possibilities. This complexity will seep into every method on the class. This design is almost certain to lead to confusion, a proliferation of null
checks, and bugs.
A better design would wait until all the data used by the class is available:
class
UserPosts
{
user
:UserInfo
;
posts
:Post
[];
constructor
(
user
:UserInfo
,
posts
:Post
[])
{
this
.
user
=
user
;
this
.
posts
=
posts
;
}
static
async
init
(
userId
:string
)
:
Promise
<
UserPosts
>
{
const
[
user
,
posts
]
=
await
Promise
.
all
([
fetchUser
(
userId
),
fetchPostsForUser
(
userId
)
]);
return
new
UserPosts
(
user
,
posts
);
}
getUserName() {
return
this
.
user
.
name
;
}
}
Now the UserPosts
class is fully non-null
, and it’s easy to write correct methods on it. Of course, if you need to perform operations while data is partially loaded, then you’ll need to deal with the multiplicity of null
and non-null
states.
(Don’t be tempted to replace nullable properties with Promises. This tends to lead to even more confusing code and forces all your methods to be async. Promises clarify the code that loads data but tend to have the opposite effect on the class that uses that data.)
Things to Remember
-
Avoid designs in which one value being
null
or notnull
is implicitly related to another value beingnull
or notnull
. -
Push
null
values to the perimeter of your API by making larger objects eithernull
or fully non-null
. This will make code clearer both for human readers and for the type checker. -
Consider creating a fully non-
null
class and constructing it when all values are available. -
While
strictNullChecks
may flag many issues in your code, it’s indispensable for surfacing the behavior of functions with respect to null values.
Item 32: Prefer Unions of Interfaces to Interfaces of Unions
If you create an interface whose properties are union types, you should ask whether the type would make more sense as a union of more precise interfaces.
Suppose you’re building a vector drawing program and want to define an interface for layers with specific geometry types:
interface
Layer
{
layout
:FillLayout
|
LineLayout
|
PointLayout
;
paint
:FillPaint
|
LinePaint
|
PointPaint
;
}
The layout
field controls how and where the shapes are drawn (rounded corners? straight?), while the paint
field controls styles (is the line blue? thick? thin? dashed?).
Would it make sense to have a layer whose layout
is LineLayout
but whose paint
property is FillPaint
? Probably not. Allowing this possibility makes using the library more error-prone and makes this interface difficult to work with.
A better way to model this is with separate interfaces for each type of layer:
interface
FillLayer
{
layout
:FillLayout
;
paint
:FillPaint
;
}
interface
LineLayer
{
layout
:LineLayout
;
paint
:LinePaint
;
}
interface
PointLayer
{
layout
:PointLayout
;
paint
:PointPaint
;
}
type
Layer
=
FillLayer
|
LineLayer
|
PointLayer
;
By defining Layer
in this way, you’ve excluded the possibility of mixed layout
and paint
properties. This is an example of following Item 28’s advice to prefer types that only represent valid states.
The most common example of this pattern is the “tagged union” (or “discriminated union”). In this case one of the properties is a union of string literal types:
interface
Layer
{
type
:'fill'
|
'line'
|
'point'
;
layout
:FillLayout
|
LineLayout
|
PointLayout
;
paint
:FillPaint
|
LinePaint
|
PointPaint
;
}
As before, would it make sense to have type: 'fill'
but then a LineLayout
and PointPaint
? Certainly not. Convert Layer
to a union of interfaces to exclude this possibility:
interface
FillLayer
{
type
:'fill'
;
layout
:FillLayout
;
paint
:FillPaint
;
}
interface
LineLayer
{
type
:'line'
;
layout
:LineLayout
;
paint
:LinePaint
;
}
interface
PointLayer
{
type
:'paint'
;
layout
:PointLayout
;
paint
:PointPaint
;
}
type
Layer
=
FillLayer
|
LineLayer
|
PointLayer
;
The type
property is the “tag” and can be used to determine which type of Layer
you’re working with at runtime. TypeScript is also able to narrow the type of Layer
based on the tag:
function
drawLayer
(
layer
:Layer
)
{
if
(
layer
.
type
===
'fill'
)
{
const
{
paint
}
=
layer
;
// Type is FillPaint
const
{
layout
}
=
layer
;
// Type is FillLayout
}
else
if
(
layer
.
type
===
'line'
)
{
const
{
paint
}
=
layer
;
// Type is LinePaint
const
{
layout
}
=
layer
;
// Type is LineLayout
}
else
{
const
{
paint
}
=
layer
;
// Type is PointPaint
const
{
layout
}
=
layer
;
// Type is PointLayout
}
}
By correctly modeling the relationship between the properties in this type, you help TypeScript check your code’s correctness. The same code involving the initial Layer
definition would have been cluttered with type assertions.
Because they work so well with TypeScript’s type checker, tagged unions are ubiquitous in TypeScript code. Recognize this pattern and apply it when you can. If you can represent a data type in TypeScript with a tagged union, it’s usually a good idea to do so. If you think of optional fields as a union of their type and undefined
, then they fit this pattern as well. Consider this type:
interface
Person
{
name
:string
;
// These will either both be present or not be present
placeOfBirth?
:string
;
dateOfBirth?
:Date
;
}
The comment with type information is a strong sign that there might be a problem (Item 30). There is a relationship between the placeOfBirth
and dateOfBirth
fields that you haven’t told TypeScript about.
A better way to model this is to move both of these properties into a single object. This is akin to moving null
values to the perimeter (Item 31):
interface
Person
{
name
:string
;
birth
?:
{
place
:string
;
date
:Date
;
}
}
Now TypeScript complains about values with a place but no date of birth:
const
alanT
:Person
=
{
name
:
'Alan Turing'
,
birth
:
{
// ~~~~ Property 'date' is missing in type
// '{ place: string; }' but required in type
// '{ place: string; date: Date; }'
place
:
'London'
}
}
Additionally, a function that takes a Person
object only needs to do a single check:
function
eulogize
(
p
:Person
)
{
console
.
log
(
p
.
name
);
const
{
birth
}
=
p
;
if
(
birth
)
{
console
.
log
(
`was born on
${
birth
.
date
}
in
${
birth
.
place
}
.`
);
}
}
If the structure of the type is outside your control (e.g., it’s coming from an API), then you can still model the relationship between these fields using a now-familiar union of interfaces:
interface
Name
{
name
:string
;
}
interface
PersonWithBirth
extends
Name
{
placeOfBirth
:string
;
dateOfBirth
:Date
;
}
type
Person
=
Name
|
PersonWithBirth
;
Now you get some of the same benefits as with the nested object:
function
eulogize
(
p
:Person
)
{
if
(
'placeOfBirth'
in
p
)
{
p
// Type is PersonWithBirth
const
{
dateOfBirth
}
=
p
// OK, type is Date
}
}
In both cases, the type definition makes the relationship between the properties more clear.
Things to Remember
-
Interfaces with multiple properties that are union types are often a mistake because they obscure the relationships between these properties.
-
Unions of interfaces are more precise and can be understood by TypeScript.
-
Consider adding a “tag” to your structure to facilitate TypeScript’s control flow analysis. Because they are so well supported, tagged unions are ubiquitous in TypeScript code.
Item 33: Prefer More Precise Alternatives to String Types
The domain of the string
type is big: "x"
and "y"
are in it, but so is the complete text of Moby Dick (it starts "Call me Ishmael…"
and is about 1.2 million characters long). When you declare a variable of type string
, you should ask whether a narrower type would be more appropriate.
Suppose you’re building a music collection and want to define a type for an album. Here’s an attempt:
interface
Album
{
artist
:string
;
title
:string
;
releaseDate
:string
;
// YYYY-MM-DD
recordingType
:string
;
// E.g., "live" or "studio"
}
The prevalence of string
types and the type information in comments (see Item 30) are strong indications that this interface
isn’t quite right. Here’s what can go wrong:
const
kindOfBlue
:Album
=
{
artist
:
'Miles Davis'
,
title
:
'Kind of Blue'
,
releaseDate
:
'August 17th, 1959'
,
// Oops!
recordingType
:
'Studio'
,
// Oops!
};
// OK
The releaseDate
field is incorrectly formatted (according to the comment) and "Studio"
is capitalized where it should be lowercase. But these values are both strings, so this object is assignable to Album
and the type checker doesn’t complain.
These broad string
types can mask errors for valid Album
objects, too. For example:
function
recordRelease
(
title
:string
,
date
:string
)
{
/* ... */
}
recordRelease
(
kindOfBlue
.
releaseDate
,
kindOfBlue
.
title
);
// OK, should be error
The parameters are reversed in the call to recordRelease
but both are strings, so the type checker doesn’t complain. Because of the prevalence of string
types, code like this is sometimes called “stringly typed.”
Can you make the types narrower to prevent these sorts of issues? While the complete text of Moby Dick would be a ponderous artist name or album title, it’s at least plausible. So string
is appropriate for these fields. For the releaseDate
field it’s better to just use a Date
object and avoid issues around formatting. Finally, for the recordingType
field, you can define a union type with just two values (you could also use an enum
, but I generally recommend avoiding these; see Item 53):
type
RecordingType
=
'studio'
|
'live'
;
interface
Album
{
artist
:string
;
title
:string
;
releaseDate
:Date
;
recordingType
:RecordingType
;
}
With these changes TypeScript is able to do a more thorough check for errors:
const
kindOfBlue
:Album
=
{
artist
:
'Miles Davis'
,
title
:
'Kind of Blue'
,
releaseDate
:new
Date
(
'1959-08-17'
),
recordingType
:
'Studio'
// ~~~~~~~~~~~~ Type '"Studio"' is not assignable to type 'RecordingType'
};
There are advantages to this approach beyond stricter checking. First, explicitly defining the type ensures that its meaning won’t get lost as it’s passed around. If you wanted to find albums of just a certain recording type, for instance, you might define a function like this:
function
getAlbumsOfType
(
recordingType
:string
)
:
Album
[]
{
// ...
}
How does the caller of this function know what recordingType
is expected to be? It’s just a string
. The comment explaining that it’s "studio"
or "live"
is hidden in the definition of Album
, where the user might not think to look.
Second, explicitly defining a type allows you attach documentation to it (see Item 48):
/** What type of environment was this recording made in? */
type
RecordingType
=
'live'
|
'studio'
;
When you change getAlbumsOfType
to take a RecordingType
, the caller is able to click through and see the documentation (see Figure 4-1).
Another common misuse of string
is in function parameters. Say you want to write a function that pulls out all the values for a single field in an array. The Underscore library calls this “pluck”:
function
pluck
(
records
,
key
)
{
return
records
.
map
(
r
=>
r
[
key
]);
}
How would you type this? Here’s an initial attempt:
function
pluck
(
records
:any
[],
key
:string
)
:
any
[]
{
return
records
.
map
(
r
=>
r
[
key
]);
}
This type checks but isn’t great. The any
types are problematic, particularly on the return value (see Item 38). The first step to improving the type signature is introducing a generic type parameter:
function
pluck
<
T
>
(
records
:T
[],
key
:string
)
:
any
[]
{
return
records
.
map
(
r
=>
r
[
key
]);
// ~~~~~~ Element implicitly has an 'any' type
// because type '{}' has no index signature
}
TypeScript is now complaining that the string
type for key
is too broad. And it’s right to do so: if you pass in an array of Album
s then there are only four valid values for key
(“artist,” “title,” “releaseDate,” and “recordingType”), as opposed to the vast set of strings. This is precisely what the keyof Album
type is:
type
K
=
keyof
Album
;
// Type is "artist" | "title" | "releaseDate" | "recordingType"
So the fix is to replace string
with keyof T
:
function
pluck
<
T
>
(
records
:T
[],
key
:keyof
T
)
{
return
records
.
map
(
r
=>
r
[
key
]);
}
This passes the type checker. We’ve also let TypeScript infer the return type. How does it do? If you mouse over pluck
in your editor, the inferred type is:
function
pluck
<
T
>
(
record
:T
[],
key
:keyof
T
)
:
T
[
keyof
T
][]
T[keyof T]
is the type of any possible value in T
. If you’re passing in a single string as the key
, this is too broad. For example:
const
releaseDates
=
pluck
(
albums
,
'releaseDate'
);
// Type is (string | Date)[]
The type should be Date[]
, not (string | Date)[]
. While keyof T
is much narrower than string
, it’s still too broad. To narrow it further, we need to introduce a second generic parameter that is a subset of keyof T
(probably a single value):
function
pluck
<
T
,
K
extends
keyof
T
>
(
records
:T
[],
key
:K
)
:
T
[
K
][]
{
return
records
.
map
(
r
=>
r
[
key
]);
}
(For more on extends
in this context, see Item 14.)
The type signature is now completely correct. We can check this by calling pluck
in a few different ways:
pluck
(
albums
,
'releaseDate'
);
// Type is Date[]
pluck
(
albums
,
'artist'
);
// Type is string[]
pluck
(
albums
,
'recordingType'
);
// Type is RecordingType[]
pluck
(
albums
,
'recordingDate'
);
// ~~~~~~~~~~~~~~~ Argument of type '"recordingDate"' is not
// assignable to parameter of type ...
The language service is even able to offer autocomplete on the keys of Album
(as shown in Figure 4-2).
string
has some of the same problems as any
: when used inappropriately, it permits invalid values and hides relationships between types. This thwarts the type checker and can hide real bugs. TypeScript’s ability to define subsets of string
is a powerful way to bring type safety to JavaScript code. Using more precise types will both catch errors and improve the readability of your code.
Things to Remember
-
Avoid “stringly typed” code. Prefer more appropriate types where not every
string
is a possibility. -
Prefer a union of string literal types to
string
if that more accurately describes the domain of a variable. You’ll get stricter type checking and improve the development experience. -
Prefer
keyof T
tostring
for function parameters that are expected to be properties of an object.
Item 34: Prefer Incomplete Types to Inaccurate Types
In writing type declarations you’ll inevitably find situations where you can model behavior in a more precise or less precise way. Precision in types is generally a good thing because it will help your users catch bugs and take advantage of the tooling that TypeScript provides. But take care as you increase the precision of your type declarations: it’s easy to make mistakes, and incorrect types can be worse than no types at all.
Suppose you are writing type declarations for GeoJSON, a format we’ve seen before in Item 31. A GeoJSON Geometry can be one of a few types, each of which have differently shaped coordinate arrays:
interface
Point
{
type
:'Point'
;
coordinates
:number
[];
}
interface
LineString
{
type
:'LineString'
;
coordinates
:number
[][];
}
interface
Polygon
{
type
:'Polygon'
;
coordinates
:number
[][][];
}
type
Geometry
=
Point
|
LineString
|
Polygon
;
// Also several others
This is fine, but number[]
for a coordinate is a bit imprecise. Really these are latitudes and longitudes, so perhaps a tuple type would be better:
type
GeoPosition
=
[
number
,
number
];
interface
Point
{
type
:'Point'
;
coordinates
:GeoPosition
;
}
// Etc.
You publish your more precise types to the world and wait for the adulation to roll in. Unfortunately, a user complains that your new types have broken everything. Even though you’ve only ever used latitude and longitude, a position in GeoJSON is allowed to have a third element, an elevation, and potentially more. In an attempt to make the type declarations more precise, you’ve gone too far and made the types inaccurate! To continue using your type declarations, your user will have to introduce type assertions or silence the type checker entirely with as any
.
As another example, consider trying to write type declarations for a Lisp-like language defined in JSON:
12 "red" ["+", 1, 2] // 3 ["/", 20, 2] // 10 ["case", [">", 20, 10], "red", "blue"] // "red" ["rgb", 255, 0, 127] // "#FF007F"
The Mapbox library uses a system like this to determine the appearance of map features across many devices. There’s a whole spectrum of precision with which you could try to type this:
-
Allow anything.
-
Allow strings, numbers, and arrays.
-
Allow strings, numbers, and arrays starting with known function names.
-
Make sure each function gets the correct number of arguments.
-
Make sure each function gets the correct type of arguments.
The first two options are straightforward:
type
Expression1
=
any
;
type
Expression2
=
number
|
string
|
any
[];
Beyond this, you should introduce a test set of expressions that are valid and expressions that are not. As you make your types more precise, this will help prevent regressions (see Item 52):
const
tests
:Expression2
[]
=
[
10
,
"red"
,
true
,
// ~~~ Type 'true' is not assignable to type 'Expression2'
[
"+"
,
10
,
5
],
[
"case"
,
[
">"
,
20
,
10
],
"red"
,
"blue"
,
"green"
],
// Too many values
[
"**"
,
2
,
31
],
// Should be an error: no "**" function
[
"rgb"
,
255
,
128
,
64
],
[
"rgb"
,
255
,
0
,
127
,
0
]
// Too many values
];
To go to the next level of precision you can use a union of string literal types as the first element of a tuple:
type
FnName
=
'+'
|
'-'
|
'*'
|
'/'
|
'>'
|
'<'
|
'case'
|
'rgb'
;
type
CallExpression
=
[
FnName
,
...
any
[]];
type
Expression3
=
number
|
string
|
CallExpression
;
const
tests
:Expression3
[]
=
[
10
,
"red"
,
true
,
// ~~~ Type 'true' is not assignable to type 'Expression3'
[
"+"
,
10
,
5
],
[
"case"
,
[
">"
,
20
,
10
],
"red"
,
"blue"
,
"green"
],
[
"**"
,
2
,
31
],
// ~~~~~~~~~~~ Type '"**"' is not assignable to type 'FnName'
[
"rgb"
,
255
,
128
,
64
],
[
"rgb"
,
255
,
0
,
127
,
0
]
// Too many values
];
There’s one new caught error and no regressions. Pretty good!
What if you want to make sure that each function gets the correct number of arguments? This gets trickier since the type now needs to be recursive to reach down into all the function calls. TypeScript allows this, though we do need to take some care to convince the type checker that our recursion isn’t infinite. In this case that means defining CaseCall
(which must be an array of even length) with an interface
rather than a type
. This is possible, if a bit awkward:
type
Expression4
=
number
|
string
|
CallExpression
;
type
CallExpression
=
MathCall
|
CaseCall
|
RGBCall
;
type
MathCall
=
[
'+'
|
'-'
|
'/'
|
'*'
|
'>'
|
'<'
,
Expression4
,
Expression4
,
];
interface
CaseCall
{
0
:
'case'
;
1
:Expression4
;
2
:Expression4
;
3
:Expression4
;
4?
:Expression4
;
5?
:Expression4
;
// etc.
length
:4
|
6
|
8
|
10
|
12
|
14
|
16
;
// etc.
}
type
RGBCall
=
[
'rgb'
,
Expression4
,
Expression4
,
Expression4
];
const
tests
:Expression4
[]
=
[
10
,
"red"
,
true
,
// ~~~ Type 'true' is not assignable to type 'Expression4'
[
"+"
,
10
,
5
],
[
"case"
,
[
">"
,
20
,
10
],
"red"
,
"blue"
,
"green"
],
// ~~~~~~ ~~~~~~~
// Type '"case"' is not assignable to type '"rgb"'.
// Type 'string' is not assignable to type 'undefined'.
[
"**"
,
2
,
31
],
// ~~~~ Type '"**"' is not assignable to type '"+" | "-" | "/" | ...
[
"rgb"
,
255
,
128
,
64
],
[
"rgb"
,
255
,
0
,
127
,
0
]
// ~ Type 'number' is not assignable to type 'undefined'.
];
Now all the invalid expressions produce errors. And it’s interesting that you can express something like “an array of even length” using a TypeScript interface
. But some of these error messages aren’t very good, particularly the one about "case"
not being assignable to "rgb"
.
Is this an improvement over the previous, less precise types? The fact that you get errors for some incorrect usages is a win, but confusing error messages will make this type more difficult to work with. Language services are as much a part of the TypeScript experience as type checking (see Item 6), so it’s a good idea to look at the error messages resulting from your type declarations and try autocomplete in situations where it should work. If your new type declarations are more precise but break autocomplete, then they’ll make for a less enjoyable TypeScript development experience.
The complexity of this type declaration has also increased the odds that a bug will creep in. For example, Expression4
requires that all math operators take two parameters, but the Mapbox expression spec says that +
and *
can take more. Also, -
can take a single parameter, in which case it negates its input. Expression4
incorrectly flags errors in all of these:
const
okExpressions
:Expression4
[]
=
[
[
'-'
,
12
],
// ~~~ Type '"-"' is not assignable to type '"rgb"'.
[
'+'
,
1
,
2
,
3
],
// ~~~ Type '"+"' is not assignable to type '"rgb"'.
[
'*'
,
2
,
3
,
4
],
// ~~~ Type '"*"' is not assignable to type '"rgb"'.
];
Once again, in trying to be more precise we’ve overshot and become inaccurate. These inaccuracies can be corrected, but you’ll want to expand your test set to convince yourself that you haven’t missed anything else. Complex code generally requires more tests, and the same is true of types.
As you refine types, it can be helpful to think of the “uncanny valley” metaphor. Refining very imprecise types like any
is usually helpful. But as your types get more precise, the expectation that they’ll also be accurate increases. You’ll start to rely on the types more, and so inaccuracies will produce bigger problems.
Things to Remember
-
Avoid the uncanny valley of type safety: incorrect types are often worse than no types.
-
If you cannot model a type accurately, do not model it inaccurately! Acknowledge the gaps using
any
orunknown
. -
Pay attention to error messages and autocomplete as you make typings increasingly precise. It’s not just about correctness: developer experience matters, too.
Item 35: Generate Types from APIs and Specs, Not Data
The other items in this chapter have discussed the many benefits of designing your types well and shown what can go wrong if you don’t. A well-designed type makes TypeScript a pleasure to use, while a poorly designed one can make it miserable. But this does put quite a bit of pressure on type design. Wouldn’t it be nice if you didn’t have to do this yourself?
At least some of your types are likely to come from outside your program: file formats, APIs, or specs. In these cases you may be able to avoid writing types by generating them instead. If you do this, the key is to generate types from specifications, rather than from example data. When you generate types from a spec, TypeScript will help ensure that you haven’t missed any cases. When you generate types from data, you’re only considering the examples you’ve seen. You might be missing important edge cases that could break your program.
In Item 31 we wrote a function to calculate the bounding box of a GeoJSON Feature. Here’s what it looked like:
function
calculateBoundingBox
(
f
:GeoJSONFeature
)
:
BoundingBox
|
null
{
let
box
:BoundingBox
|
null
=
null
;
const
helper
=
(
coords
:any
[])
=>
{
// ...
};
const
{
geometry
}
=
f
;
if
(
geometry
)
{
helper
(
geometry
.
coordinates
);
}
return
box
;
}
The GeoJSONFeature
type was never explicitly defined. You could write it using some of the examples from Item 31. But a better approach is to use the formal GeoJSON spec.1 Fortunately for us, there are already TypeScript type declarations for it on DefinitelyTyped. You can add these in the usual way:
$ npm install --save-dev @types/geojson + @types/geojson@7946.0.7
When you plug in the GeoJSON declarations, TypeScript immediately flags an error:
import
{
Feature
}
from
'geojson'
;
function
calculateBoundingBox
(
f
:Feature
)
:
BoundingBox
|
null
{
let
box
:BoundingBox
|
null
=
null
;
const
helper
=
(
coords
:any
[])
=>
{
// ...
};
const
{
geometry
}
=
f
;
if
(
geometry
)
{
helper
(
geometry
.
coordinates
);
// ~~~~~~~~~~~
// Property 'coordinates' does not exist on type 'Geometry'
// Property 'coordinates' does not exist on type
// 'GeometryCollection'
}
return
box
;
}
The problem is that your code assumes a geometry will have a coordinates
property. This is true for many geometries, including points, lines, and polygons. But a GeoJSON geometry can also be a GeometryCollection
, a heterogeneous collection of other geometries. Unlike the other geometry types, it does not have a coordinates
property.
If you call calculateBoundingBox
on a Feature whose geometry is a GeometryCollection
, it will throw an error about not being able to read property 0
of undefined
. This is a real bug! And we caught it using type definitions from a spec.
One option for fixing it is to explicitly disallow GeometryCollection
s, as shown here:
const
{
geometry
}
=
f
;
if
(
geometry
)
{
if
(
geometry
.
type
===
'GeometryCollection'
)
{
throw
new
Error
(
'GeometryCollections are not supported.'
);
}
helper
(
geometry
.
coordinates
);
// OK
}
TypeScript is able to refine the type of geometry
based on the check, so the reference to geometry.coordinates
is allowed. If nothing else, this results in a clearer error message for the user.
But the better solution is to support all the types of geometry! You can do this by pulling out another helper function:
const
geometryHelper
=
(
g
:Geometry
)
=>
{
if
(
geometry
.
type
===
'GeometryCollection'
)
{
geometry
.
geometries
.
forEach
(
geometryHelper
);
}
else
{
helper
(
geometry
.
coordinates
);
// OK
}
}
const
{
geometry
}
=
f
;
if
(
geometry
)
{
geometryHelper
(
geometry
);
}
Had you written type declarations for GeoJSON yourself, you would have based them off of your understanding and experience with the format. This might not have included GeometryCollection
s and would have led to a false sense of security about your code’s correctness. Using types based on a spec gives you confidence that your code will work with all values, not just the ones you’ve seen.
Similar considerations apply to API calls: if you can generate types from the specification of an API, then it is usually a good idea to do so. This works particularly well with APIs that are typed themselves, such as GraphQL.
A GraphQL API comes with a schema that specifies all the possible queries and interfaces using a type system somewhat similar to TypeScript. You write queries that request specific fields in these interfaces. For example, to get information about a repository using the GitHub GraphQL API you might write:
query { repository(owner: "Microsoft", name: "TypeScript") { createdAt description } }
The result is:
{
"data"
:
{
"repository"
:
{
"createdAt"
:
"2014-06-17T15:28:39Z"
,
"description"
:
"TypeScript is a superset of JavaScript that compiles to JavaScript."
}
}
}
The beauty of this approach is that you can generate TypeScript types for your specific query. As with the GeoJSON example, this helps ensure that you model the relationships between types and their nullability accurately.
Here’s a query to get the open source license for a GitHub repository:
query getLicense($owner:String!, $name:String!){ repository(owner:$owner, name:$name) { description licenseInfo { spdxId name } } }
$owner
and $name
are GraphQL variables which are themselves typed. The type syntax is similar enough to TypeScript that it can be confusing to go back and forth. String
is a GraphQL type—it would be string
in TypeScript (see Item 10). And while TypeScript types are not nullable, types in GraphQL are. The !
after the type indicates that it is guaranteed to not be null.
There are many tools to help you go from a GraphQL query to TypeScript types. One is Apollo. Here’s how you use it:
$ apollo client:codegen \ --endpoint https://api.github.com/graphql \ --includes license.graphql \ --target typescript Loading Apollo Project Generating query files with 'typescript' target - wrote 2 files
You need a GraphQL schema to generate types for a query. Apollo gets this from the api.github.com/graphql
endpoint. The output looks like this:
export
interface
getLicense_repository_licenseInfo
{
__typename
:
"License"
;
/** Short identifier specified by <https://spdx.org/licenses> */
spdxId
:string
|
null
;
/** The license full name specified by <https://spdx.org/licenses> */
name
:string
;
}
export
interface
getLicense_repository
{
__typename
:
"Repository"
;
/** The description of the repository. */
description
:string
|
null
;
/** The license associated with the repository */
licenseInfo
:getLicense_repository_licenseInfo
|
null
;
}
export
interface
getLicense
{
/** Lookup a given repository by the owner and repository name. */
repository
:getLicense_repository
|
null
;
}
export
interface
getLicenseVariables
{
owner
:string
;
name
:string
;
}
The important bits to note here are that:
-
Interfaces are generated for both the query parameters (
getLicenseVariables
) and the response (getLicense
). -
Nullability information is transferred from the schema to the response interfaces. The
repository
,description
,licenseInfo
, andspdxId
fields are nullable, whereas the licensename
and the query variables are not. -
Documentation is transferred as JSDoc so that it appears in your editor (Item 48). These comments come from the GraphQL schema itself.
This type information helps ensure that you use the API correctly. If your queries change, the types will change. If the schema changes, then so will your types. There is no risk that your types and reality diverge since they are both coming from a single source of truth: the GraphQL schema.
What if there’s no spec or official schema available? Then you’ll have to generate types from data. Tools like quicktype
can help with this. But be aware that your types may not match reality: there may be edge cases that you’ve missed.
Even if you’re not aware of it, you are already benefiting from code generation. TypeScript’s type declarations for the browser DOM API are generated from the official interfaces (see Item 55). This ensures that they correctly model a complicated system and helps TypeScript catch errors and misunderstandings in your own code.
Item 36: Name Types Using the Language of Your Problem Domain
There are only two hard problems in Computer Science: cache invalidation and naming things.
Phil Karlton
This book has had much to say about the shape of types and the sets of values in their domains, but much less about what you name your types. But this is an important part of type design, too. Well-chosen type, property, and variable names can clarify intent and raise the level of abstraction of your code and types. Poorly chosen types can obscure your code and lead to incorrect mental models.
Suppose you’re building out a database of animals. You create an interface to represent one:
interface
Animal
{
name
:string
;
endangered
:boolean
;
habitat
:string
;
}
const
leopard
:Animal
=
{
name
:
'Snow Leopard'
,
endangered
:false
,
habitat
:
'tundra'
,
};
There are a few issues here:
-
name
is a very general term. What sort of name are you expecting? A scientific name? A common name? -
The boolean
endangered
field is also ambiguous. What if an animal is extinct? Is the intent here “endangered or worse?” Or does it literally mean endangered? -
The
habitat
field is very ambiguous, not just because of the overly broadstring
type (Item 33) but also because it’s unclear what’s meant by “habitat.” -
The variable name is
leopard
, but the value of thename
property is “Snow Leopard.” Is this distinction meaningful?
Here’s a type declaration and value with less ambiguity:
interface
Animal
{
commonName
:string
;
genus
:string
;
species
:string
;
status
:ConservationStatus
;
climates
:KoppenClimate
[];
}
type
ConservationStatus
=
'EX'
|
'EW'
|
'CR'
|
'EN'
|
'VU'
|
'NT'
|
'LC'
;
type
KoppenClimate
=
|
'Af'
|
'Am'
|
'As'
|
'Aw'
|
'BSh'
|
'BSk'
|
'BWh'
|
'BWk'
|
'Cfa'
|
'Cfb'
|
'Cfc'
|
'Csa'
|
'Csb'
|
'Csc'
|
'Cwa'
|
'Cwb'
|
'Cwc'
|
'Dfa'
|
'Dfb'
|
'Dfc'
|
'Dfd'
|
'Dsa'
|
'Dsb'
|
'Dsc'
|
'Dwa'
|
'Dwb'
|
'Dwc'
|
'Dwd'
|
'EF'
|
'ET'
;
const
snowLeopard
:Animal
=
{
commonName
:
'Snow Leopard'
,
genus
:
'Panthera'
,
species
:
'Uncia'
,
status
:
'VU'
,
// vulnerable
climates
:
[
'ET'
,
'EF'
,
'Dfd'
],
// alpine or subalpine
};
This makes a number of improvements:
-
name
has been replaced with more specific terms:commonName
,genus
, andspecies
. -
endangered
has becomestatus
, aConservationStatus
type which uses a standard classification system from the IUCN. -
habitat
has becomeclimates
and uses another standard taxonomy, the Köppen climate classification.
If you needed more information about the fields in the first version of this type, you’d have to go find the person who wrote them and ask. In all likelihood, they’ve left the company or don’t remember. Worse yet, you might run git blame
to find out who wrote these lousy types, only to find that it was you!
The situation is much improved with the second version. If you want to learn more about the Köppen climate classification system or track down what the precise meaning of a conservation status is, then there are myriad resources online to help you.
Every domain has specialized vocabulary to describe its subject. Rather than inventing your own terms, try to reuse terms from the domain of your problem. These vocabularies have often been honed over years, decades, or centuries and are well understood by people in the field. Using these terms will help you communicate with users and increase the clarity of your types.
Take care to use domain vocabulary accurately: co-opting the language of a domain to mean something different is even more confusing than inventing your own.
Here are a few other rules to keep in mind as you name types, properties, and variables:
-
Make distinctions meaningful. In writing and speech it can be tedious to use the same word over and over. We introduce synonyms to break the monotony. This makes prose more enjoyable to read, but it has the opposite effect on code. If you use two different terms, make sure you’re drawing a meaningful distinction. If not, you should use the same term.
-
Avoid vague, meaningless names like “data,” “info,” “thing,” “item,” “object,” or the ever-popular “entity.” If Entity has a specific meaning in your domain, fine. But if you’re using it because you don’t want to think of a more meaningful name, then you’ll eventually run into trouble.
-
Name things for what they are, not for what they contain or how they are computed.
Directory
is more meaningful thanINodeList
. It allows you to think about a directory as a concept, rather than in terms of its implementation. Good names can increase your level of abstraction and decrease your risk of inadvertent collisions.
Item 37: Consider “Brands” for Nominal Typing
Item 4 discussed structural (“duck”) typing and how it can sometimes lead to surprising results:
interface
Vector2D
{
x
:number
;
y
:number
;
}
function
calculateNorm
(
p
:Vector2D
)
{
return
Math
.
sqrt
(
p
.
x
*
p
.
x
+
p
.
y
*
p
.
y
);
}
calculateNorm
({
x
:3
,
y
:4
});
// OK, result is 5
const
vec3D
=
{
x
:3
,
y
:4
,
z
:1
};
calculateNorm
(
vec3D
);
// OK! result is also 5
What if you’d like calculateNorm
to reject 3D vectors? This goes against the structural typing model of TypeScript but is certainly more mathematically correct.
One way to achieve this is with nominal typing. With nominal typing, a value is a Vector2D
because you say it is, not because it has the right shape. To approximate this in TypeScript, you can introduce a “brand” (think cows, not Coca-Cola):
interface
Vector2D
{
_brand
:
'2d'
;
x
:number
;
y
:number
;
}
function
vec2D
(
x
:number
,
y
:number
)
:
Vector2D
{
return
{
x
,
y
,
_brand
:
'2d'
};
}
function
calculateNorm
(
p
:Vector2D
)
{
return
Math
.
sqrt
(
p
.
x
*
p
.
x
+
p
.
y
*
p
.
y
);
// Same as before
}
calculateNorm
(
vec2D
(
3
,
4
));
// OK, returns 5
const
vec3D
=
{
x
:3
,
y
:4
,
z
:1
};
calculateNorm
(
vec3D
);
// ~~~~~ Property '_brand' is missing in type...
The brand ensures that the vector came from the right place. Granted there’s nothing stopping you from adding _brand: '2d'
to the vec3D
value. But this is moving from the accidental into the malicious. This sort of brand is typically enough to catch inadvertent misuses of functions.
Interestingly, you can get many of the same benefits as explicit brands while operating only in the type system. This removes runtime overhead and also lets you brand built-in types like string
or number
where you can’t attach additional properties.
For instance, what if you have a function that operates on the filesystem and requires an absolute (as opposed to a relative) path? This is easy to check at runtime (does the path start with “/”?) but not so easy in the type system.
Here’s an approach with brands:
type
AbsolutePath
=
string
&
{
_brand
:
'abs'
};
function
listAbsolutePath
(
path
:AbsolutePath
)
{
// ...
}
function
isAbsolutePath
(
path
:string
)
:
path
is
AbsolutePath
{
return
path
.
startsWith
(
'/'
);
}
You can’t construct an object that is a string
and has a _brand
property. This is purely a game with the type system.
If you have a string
path that could be either absolute or relative, you can check using the type guard, which will refine its type:
function
f
(
path
:string
)
{
if
(
isAbsolutePath
(
path
))
{
listAbsolutePath
(
path
);
}
listAbsolutePath
(
path
);
// ~~~~ Argument of type 'string' is not assignable
// to parameter of type 'AbsolutePath'
}
This sort of approach could be helpful in documenting which functions expect absolute or relative paths and which type of path each variable holds. It is not an ironclad guarantee, though: path as AbsolutePath
will succeed for any string
. But if you avoid these sorts of assertions, then the only way to get an AbsolutePath
is to be given one or to check, which is exactly what you want.
This approach can be used to model many properties that cannot be expressed within the type system. For example, using binary search to find an element in a list:
function
binarySearch
<
T
>
(
xs
:T
[],
x
:T
)
:
boolean
{
let
low
=
0
,
high
=
xs
.
length
-
1
;
while
(
high
>=
low
)
{
const
mid
=
low
+
Math
.
floor
((
high
-
low
)
/
2
);
const
v
=
xs
[
mid
];
if
(
v
===
x
)
return
true
;
[
low
,
high
]
=
x
>
v
?
[
mid
+
1
,
high
]
:
[
low
,
mid
-
1
];
}
return
false
;
}
This works if the list is sorted, but will result in false negatives if it is not. You can’t represent a sorted list in TypeScript’s type system. But you can create a brand:
type
SortedList
<
T
>
=
T
[]
&
{
_brand
:
'sorted'
};
function
isSorted
<
T
>
(
xs
:T
[])
:
xs
is
SortedList
<
T
>
{
for
(
let
i
=
1
;
i
<
xs
.
length
;
i
++
)
{
if
(
xs
[
i
]
<
xs
[
i
-
1
])
{
return
false
;
}
}
return
true
;
}
function
binarySearch
<
T
>
(
xs
:SortedList
<
T
>
,
x
:T
)
:
boolean
{
// ...
}
In order to call this version of binarySearch
, you either need to be given a SortedList
(i.e., have a proof that the list is sorted) or prove that it’s sorted yourself using isSorted
. The linear scan isn’t great, but at least you’ll be safe!
This is a helpful perspective to have on the type checker in general. In order to call a method on an object, for instance, you either need to be given a non-null
object or prove that it’s non-null
yourself with a conditional.
You can also brand number
types—for example, to attach units:
type
Meters
=
number
&
{
_brand
:
'meters'
};
type
Seconds
=
number
&
{
_brand
:
'seconds'
};
const
meters
=
(
m
:number
)
=>
m
as
Meters
;
const
seconds
=
(
s
:number
)
=>
s
as
Seconds
;
const
oneKm
=
meters
(
1000
);
// Type is Meters
const
oneMin
=
seconds
(
60
);
// Type is Seconds
This can be awkward in practice since arithmetic operations make the numbers forget their brands:
const
tenKm
=
oneKm
*
10
;
// Type is number
const
v
=
oneKm
/
oneMin
;
// Type is number
If your code involves lots of numbers with mixed units, however, this may still be an attractive approach to documenting the expected types of numeric parameters.
Things to Remember
-
TypeScript uses structural (“duck”) typing, which can sometimes lead to surprising results. If you need nominal typing, consider attaching “brands” to your values to distinguish them.
-
In some cases you may be able to attach brands entirely in the type system, rather than at runtime. You can use this technique to model properties outside of TypeScript’s type system.
1 GeoJSON is also known as RFC 7946. The very readable spec is at http://geojson.org.
Get Effective TypeScript now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.