developers

TypeScript 3.0: Exploring Tuples and the Unknown Type

Learn how TypeScript 3.0, a typed superset of JavaScript, improves tuples type and introduces a new 'unknown' top type!

Aug 21, 201820 min read

TypeScript 3.0 is out! It comes with enhancements for the type system, compiler, and language service. This release is shipping with the following:

  • Project references let TypeScript projects depend on other TypeScript projects by allowing

    tsconfig.json
    files to reference other
    tsconfig.json
    files.

  • Support for

    defaultProps
    in JSX.

  • Tuples in rest parameters and spread expressions with optional elements.

  • New

    unknown
    top type! It's the type-safe counterpart of
    any
    .

For this blog post, we are going to focus on the enhancements made to tuples and the

unknown
type! Feel free to check the handbook for an in-depth view of TypeScript Project References.


TypeScript Quick Setup

If you want to follow along with the example in this post, you can follow these quick steps to get a TypeScript 3.0 project up and running.

If you prefer to test TypeScript 3.0 on a sandbox environment, you can use the TypeScript playground instead to follow along.

Setting Up a TypeScript Project

In any directory of your choice, create a

ts3
directory and make it your current directory:

mkdir ts3
cd ts3

Once

ts3
is the current working directory, initialize a Node.js project with default values:

npm init -y

Next, install core packages that are needed to compile and monitor changes in TypeScript files:

npm i typescript nodemon ts-node --save-dev

A TypeScript project needs a

tsconfig.json
file. This can be done in two ways: using the global
tsc
command or using
npx tsc
. I recommend using
npx
.

The

npx
command is available in
npm >= 5.2
and it lets you create a
tsconfig.json
file as follows:

npx tsc --init

Here,

npx
executes the local
typescript
package that has been installed locally.

If you prefer to do so using a global package, ensure that you install TypeScript globally and run the following:

tsc --init

You will see the following message in the command line once's that done:

Successfully created a tsconfig.json file.

You will also have a

tsconfig.json
file with sensible started defaults enabled. For the scope of this tutorial, those configuration settings are more than enough.

Compiling, Running, and Watching TypeScript

In a development world where everything build-related is now automated, an easy way to compile, run, and watch TypeScript files is needed. This can be done through

nodemon
and
ts-node
:

  • nodemon
    : It's a tool that monitors for any changes in a Node.js application directory and automatically restarts the server.

  • ts-node
    : It's a TypeScript execution and REPL for Node.js, with source map support. Works with
    typescript@>=2.0
    .

The executables of these two packages need to be run together through an

npm
script. In your code editor or IDE, update the
package.json
as follows:

{
  // ...
  "scripts": {
    "watch": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts"
  }
  // ...
}

The

watch
script is doing a lot of hard work:

nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts

The

nodemon
executable takes a
--watch
argument. The
--watch
option is followed by a string that specifies the directories that need to be watched and follows the glob pattern>).

Next, the

--exec
option is passed. The
--exec
option is used to run non-node scripts.
nodemon
will read the file extension of the script being run and monitor that extension instead of
.js
. In this case,
--exec
runs
ts-node
.

ts-node
executes any passed TypeScript files as
node
+
tsc
. In this case, it receives and executes
src/index.ts
.

In some other setups, two different shells may be used: One to compile and watch the TypeScript files and another one to run resultant JavaScript file through

node
or
nodemon
.

Finally, create a

src
folder under the project directory,
ts3
, and create
index.ts
within it.

Running a TypeScript Project

With all dependencies installed and scripts set up, you are ready to run the project. Execute the following command in the shell:

npm run watch

Messages about

nodemon
will come up. Since
index.ts
is empty as of now, there's no other output in the shell.

You are all set up! Now join me in exploring what new features come with TypeScript 3.

TypeScript TupleWare

What Are TypeScript Tuples?

TypeScript 3 comes with a couple of changes to how tuples can be used. Therefore, let's quickly review the basics of TypeScript tuples.

A tuple is a TypeScript type that works like an array with some special considerations:

  • The number of elements of the array is fixed.
  • The type of the elements is known.
  • The type of the elements of the array need not be the same.

For example, through a tuple, we can represent a value as a pair of a string and a boolean. Let's head to

index.ts
and populate it with the following code:

// Declare the tuple
let option: [string, boolean];

// Correctly initialize it
option = ["uppercase", true];

If we change value of

option
to
[true, "uppercase"]
, we'll get an error:

// Declare the tuple
let option: [string, boolean];

// Correctly initialize it
option = ["uppercase", true];

// Incorrect value order
option = [true, "uppercase"];
src/index.ts(8,11): error TS2322: Type 'true' is not assignable to type 'string'.
src/index.ts(8,17): error TS2322: Type 'string' is not assignable to type 'boolean'.

Let's delete the incorrect example from our code and move forward with the understanding that, with tuples, the order of values is critical. Relying on order can make code difficult to read, maintain, and use. For that reason, it's a good idea to use tuples with data that is related to each other in a sequential manner. That way, accessing the elements in order is part of a predictable and expected pattern.

The coordinates of a point are examples of data that is sequential. A three-dimensional coordinate always comes in a three-number pattern:

(x, y, z)

On a Cartesian plane, the order of the points will always be sequential. We could represent this three-dimensional coordinate as the following tuple:

type Point3D = [number, number, number];

Therefore,

Point3D[0]
,
Point3D[1]
, and
Point3D[2]
would be logically digestible and easier to map than other disjointed data.

3D coordinate system example

Source

On the other hand, associated data that is loosely tied is not beneficial. For example, we could have three pieces of

customerData
that are
email
,
phoneNumber
, and
dateOfBirth
.
customerData[0]
,
customerData[1]
, and
customerData[2]
say nothing about what type of data each represents. We would need to trace the code or read the documentation to find out how the data is being mapped. This is not an ideal scenario and using an
interface
would be much better.

That's it for tuples! They provide us with a fixed size container that can store values of all kinds of types. Now, let's see what TypeScript changes about using tuples in version

3.0
of the language.

Using TypeScript Tuples in Rest Parameters

In JavaScript, the rest parameter syntax allows us to "represent an indefinite number of arguments as an array."

However, as we reviewed earlier, in TypeScript, tuples are special arrays that can contain elements of different types.

With TypeScript 3, the rest parameter syntax allows us to represent a finite number of arguments of different types as a tuple. The rest parameter expands the elements of the tuple into discrete parameters.

Let's look at the following function signature as an example:

declare function example(...args: [string, boolean, number]): void;

Here, the

args
parameter is a tuple type that contains three elements of type
string
,
boolean
, and
number
. Using the rest parameter syntax, (
...
),
args
is expanded in a way that makes the function signature above equivalent to this one:

declare function example(args_0: string, args_1: boolean, args_2: number): void;

To access

args_0
,
args_1
, and
args_2
within the body of a function, we would use array notation:
args[0]
,
args[1]
, and
args[2]
.

The goal of the rest parameter syntax is to collect "argument overflow" into a single structure: an array or a tuple.

In action, we could call the

example
function as follows:

example("TypeScript example", true, 100);

TypeScript will pack that into the following tuple since

example
, as first defined, only takes one parameter:

["TypeScript example", true, 100];

Then the rest parameter syntax unpacks it within the parameter list and makes it easily accessible to the body of the function using array index notation with the same name of the rest parameter as the name of the tuple,

args[0]
.

Using our

Point3D
tuple example, we could define a function like this:

declare function draw(...point3D: [number, number, number]): void;

As before, we would access each coordinate point as follows:

point3D[0]
for
x
,
point3D[1]
for
y
, and
point3D[2]
for
z
.

How is this different from just passing an array? A tuple type forces us to pass a number for

x
,
y
, and
z
while an array could be empty. A tuple will throw an error if we are not passing exactly 3 numbers to the
draw
function.

Spread Expressions with TypeScript Tuples

The rest parameter syntax looks very familiar to the spread operator; however, they are very different. As we learned earlier, the rest parameter syntax collects parameters into a single variable and then expands them under its variable name. On the other hand, the spread operator expands the elements of an array or object. With TypeScript 3.0, the spread operator can also expand the elements of a tuple.

Let's see this in action using our

Point3D
tuple. Let's replace the code in
index.ts
and populate it with the following:

type Point3D = [number, number, number];

const draw = (...point3D: Point3D) => {
  console.log(point3D);
};

const xyzCoordinate: Point3D = [10, 20, 10];

draw(10, 20, 10);
draw(xyzCoordinate[0], xyzCoordinate[1], xyzCoordinate[2]);
draw(...xyzCoordinate);

We create a

Point3D
to represent the
(10, 20, 10)
coordinate and store it in the
xyzCoordinate
constant. Notice that we have three ways to pass a point to the
draw
function:

  • Passing the values as literals:
draw(10, 20, 10);
  • Passing indexes to the corresponding
    xyzCoordinate
    tuple:
draw(xyzCoordinate[0], xyzCoordinate[1], xyzCoordinate[2]);
  • Using the spread operator to pass the full
    xyzCoordinate
    tuple:
draw(...xyzCoordinate);

As we can see, using the spread operator is a fast and clean option for passing a tuple as an argument.

Optional Tuple Elements

With TypeScript 3.0, our tuples can have optional elements that are specified using the

?
postfix.

We could create a generic

Point
tuple that could become two or three-dimensional depending on how many tuple elements are specified:

type Point = [number, number?, number?];

const x: Point = [10];
const xy: Point = [10, 20];
const xyz: Point = [10, 20, 10];

We could determine what kind of point is being represented by the constant by checking the

length
of the tuple. Let's replace the content of
index.ts
with the following:

type Point = [number, number?, number?];

const x: Point = [10];
const xy: Point = [10, 20];
const xyz: Point = [10, 20, 10];

console.log(x.length);
console.log(xy.length);
console.log(xyz.length);

The code above outputs the following in the console:

1
2
3

Notice that the length of each of the

Point
tuples varies by the number of elements the tuple has. The
length
property of a tuple with optional elements is the "union of the numeric literal types representing the possible lengths" as stated in the TypeScript release.

The type of the length property in the

Point
tuple type
[number, number?, number?]
is
1 | 2 | 3
.

As a rule, if we need to list multiple optional elements, they have to be listed with the postfix

?
modifier on its type at the end of the tuple. An optional element cannot have required elements to its right but it can have as many optional elements as desired instead.

The following code would result in an error:

type Point = [number, number?, number];
error TS1257: A required element cannot follow an optional element.

In

--strictNullChecks
mode, using the
?
modifier automatically includes
undefined
in the tuple element type. This behavior is similar to what TypeScript does with optional parameters.

TypeScript Tuples are like Tupperware containers

Source

Tuples are like Tupperware containers that let you store different types of food on each container. We can collect and bundle the containers and we can disperse them as well. At the same time, we can make it optional to fill a container. However, it would be a good idea to keep the containers with uncertain content at the bottom of the stack to make the containers with guaranteed content both easy to find and predictable.

Rest Elements In Tuple Types

In TypeScript 3.0, the last element of a tuple can be a rest element,

...element
. The one restriction for this rest element is that it has to be an array type. This structure is useful when we want to create open-ended tuples that may have zero or more additional elements. It's a boost over the optional
?
postfix as it allows us to specify a variable number of optional elements with the one condition that they have to be of the same type.

For example,

[string, ...number[]]
represents a tuple with a string element followed by any amount of
number
elements. This tuple could represent a student name followed by test scores.

Replace the content of

index.ts
with the following:

type TestScores = [string, ...number[]];

const thaliaTestScore = ["Thalia", ...[100, 98, 99, 100]];
const davidTestScore = ["David", ...[100, 98, 100]];

console.log(thaliaTestScore);
console.log(davidTestScore);

Output:

[ 'Thalia', 100, 98, 99, 100 ]
[ 'David', 100, 98, 100 ]

Don't forget to add the

...
operator to the
array
. Otherwise, the tuple will get the array as it second element and nothing else. The
array
elements won't spread out into the tuple:

type TestScores = [string, ...number[]];

const thaliaTestScore = ["Thalia", [100, 98, 99, 100]];
const davidTestScore = ["David", [100, 98, 100]];

console.log(thaliaTestScore);
console.log(davidTestScore);

Output:

[ 'Thalia', [ 100, 98, 99, 100 ] ]
[ 'David', [ 100, 98, 100 ] ]

TypeScript: New 'Unknown' Top Type

TypeScript 3.0 introduces a new type called

unknown
.
unknown
acts like a type-safe version of
any
by requiring us to perform some type of checking before we can use the value of the
unknown
element or any of its properties. Let's explore the rules around this wicked type!

any
is too flexible. As the name suggests, it can encompass the type of every possible value in TypeScript. What's not so ideal of this premise is that
any
doesn't require us to do any kind of checking before we make use of the properties of its value.

In the following code,

itemLocation
is defined as having type
any
and it's assigned the value of
10
, but we use it unsafely. Replace the content of
index.ts
with this:

let itemLocation: any = 10;

itemLocation.coordinates.x;
itemLocation.coordinates.y;
itemLocation.coordinates.z;

const findItem = (loc: string) => {
  console.log(loc.toLowerCase());
};

findItem(itemLocation);

itemLocation();

const iPhoneLoc = new itemLocation();

We tried to access a

coordinates
property from
itemLocation
that doesn't exist. We also passed it as an argument to a function that takes an argument of type
string
. We called it as a function. Lastly, we used it as a constructor. Throughout the execution of these statements, TypeScript throws errors but it doesn't get upset enough to stop compilation:

TypeError: Cannot read property 'x' of undefined

Let's see what happens when we change the type of

itemLocation
from
any
to
unknown
:

let itemLocation: unknown = 10;

itemLocation.coordinates.x;
itemLocation.coordinates.y;
itemLocation.coordinates.z;

const findItem = (loc: string) => {
  console.log(loc.toLowerCase());
};

findItem(itemLocation);

itemLocation();

const iPhoneLoc = new itemLocation();

Our IDE or code editor will instantly underline

itemLocation
with red in any of the instances where we are accessing its properties unsafely. This time around, TypeScript doesn't merely throw an error but actually stops compilation:

TSError: ⨯ Unable to compile TypeScript:
src/index.ts(3,1): error TS2571: Object is of type 'unknown'.
src/index.ts(4,1): error TS2571: Object is of type 'unknown'.
src/index.ts(5,1): error TS2571: Object is of type 'unknown'.
src/index.ts(11,10): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'string'.
src/index.ts(13,1): error TS2571: Object is of type 'unknown'.
src/index.ts(15,23): error TS2571: Object is of type 'unknown'.

We can use an

unknown
type if and only if we perform some form of checking on its structure. We can either check the structure of the element we want to use, or we can use type assertion to tell TypeScript that we are confident about the type of the value. Let's see this in code.

Performing an Explicit Structural Check

Let's start with the following code that uses

any
as the type of
itemLocation
:

let itemLocation: any = {
  coordinates: { x: 10, y: "cows", z: true }
};

console.log(itemLocation.coordinates.x);
console.log(itemLocation.coordinates.y);
console.log(itemLocation.coordinates.z);

This code is valid and the output in the console is as follows:

10
cows
true

However, if we change the type to

unknown
, we immediately get a compilation error:

let itemLocation: unknown = {
  coordinates: { x: 10, y: "cows", z: true }
};

console.log(itemLocation.coordinates.x);
console.log(itemLocation.coordinates.y);
console.log(itemLocation.coordinates.z);
    return new TSError(diagnosticText, diagnosticCodes)
           ^
TSError: ⨯ Unable to compile TypeScript:
src/index.ts(5,13): error TS2571: Object is of type 'unknown'.
src/index.ts(6,13): error TS2571: Object is of type 'unknown'.
src/index.ts(7,13): error TS2571: Object is of type 'unknown'.

Because

itemLocation
is of type
unknown
, despite
itemLocation
having the
coordinates
property object defined with its own
x
,
y
, and
z
properties, TypeScript won't let the compilation happen until we perform a type-check.

Let's create an

itemLocationCheck
method that checks for the structural integrity of
itemLocation
:

let itemLocation: unknown = {
  coordinates: { x: 10, y: "cows", z: true }
};

const itemLocationCheck = (
  loc: any
): loc is { coordinates: { x: any; y: any; z: any } } => {
  return (
    !!loc &&
    typeof loc === "object" &&
    "coordinates" in loc &&
    "x" in loc.coordinates &&
    "y" in loc.coordinates &&
    "z" in loc.coordinates
  );
};

if (itemLocationCheck(itemLocation)) {
  console.log(itemLocation.coordinates.x);
  console.log(itemLocation.coordinates.y);
  console.log(itemLocation.coordinates.z);
}

We get the output back in the console:

10
cows
true

When we pass

itemLocation
to
itemLocationCheck
as a parameter,
itemLocationCheck
performs a structural check on it:

  • It checks that
    loc
    is defined.
  • It checks that
    loc
    is of type
    object
    .
  • It checks that
    loc
    has a property named
    coordinates
    .
    • Once this is done, it checks that
      coordinates
      has properties named
      x
      ,
      y
      , and
      z
      .

If all these checks pass, the logic within the

if
statement is executed. These checks are enough to convince TypeScript that we have done our due diligence on checking the integrity of the
unknown
type element and it gives us permission to use it.

How strict is TypeScript with the structural check? Do we need to check for the existence of every property of the object? Let's modify

itemLocationCheck
for it not to check the
coordinates.z
property:

let itemLocation: unknown = {
  coordinates: { x: 10, y: "cows", z: true }
};

const itemLocationCheck = (
  loc: any
): loc is { coordinates: { x: any; y: any } } => {
  return (
    !!loc &&
    typeof loc === "object" &&
    "coordinates" in loc &&
    "x" in loc.coordinates &&
    "y" in loc.coordinates
  );
};

if (itemLocationCheck(itemLocation)) {
  console.log(itemLocation.coordinates.x);
  console.log(itemLocation.coordinates.y);
  console.log(itemLocation.coordinates.z);
}

The execution of the code above results in a compilation error:

TSError: ⨯ Unable to compile TypeScript:
src/index.ts(20,40): error TS2339: Property 'z' does not exist on type '{ x: any; y: any; }'.

The critical part of the structural check we are doing comes from the usage of the

is
keyword in the return type of
itemLocationCheck
.

Let's recap what the

is
keyword does in TypeScript. We define the following type predicate as the return type of
itemLocationCheck
instead of just using a
boolean
return type:

loc is { coordinates: { x: any; y: any } }

Using the type predicate has the giant benefit that when

itemLocationCheck
is called, if the function returns a truthy value, TypeScript will narrow the type to
{ coordinates: { x: any; y: any } }
in any block guarded by a call to
itemLocationCheck
as explained by StackOverflow contributor Aries Chui.

The type narrowing done by

is
creates the error within the
if
block because for TypeScript, within that block,
itemLocation
has a
coordinates
property and that
coordinates
property only has the properties
x
and
y
. It doesn't matter that outside of the scope of that block
itemLocation.coordinates.z
exists and is defined.

If we put

z
back in the return type predicate while still not performing the
"z" in loc.coordinates
check, TypeScript would not throw a compilation error:

let itemLocation: unknown = {
  coordinates: { x: 10, y: "cows", z: true }
};

const itemLocationCheck = (
  loc: any
): loc is { coordinates: { x: any; y: any; z: any } } => {
  return (
    !!loc &&
    typeof loc === "object" &&
    "coordinates" in loc &&
    "x" in loc.coordinates &&
    "y" in loc.coordinates
  );
};

if (itemLocationCheck(itemLocation)) {
  console.log(itemLocation.coordinates.x);
  console.log(itemLocation.coordinates.y);
  console.log(itemLocation.coordinates.z);
}

In essence, what TypeScript wants us to do when using the

unkown
type is for us to make it known before we use it. We do not modify the type of the element but we do modify how TypeScript sees it.

Let's explore next how we can do this using type assertion.

Performing a Type Assertion

We can also satisfy TypeScript

unknown
check by doing a type assertion. Let's use again our base example but this time let's start
itemLocation
with the
unknown
type:

let itemLocation: unknown = {
  coordinates: { x: 10, y: "cows", z: true }
};

console.log(itemLocation.coordinates.x);
console.log(itemLocation.coordinates.y);
console.log(itemLocation.coordinates.z);

As expected, we get a compilation error. Let's then create a

KnownStructure
type and use it to assert the type of
itemLocation
:

type KnownStructure = { coordinates: { x: any; y: any; z: any } };

let itemLocation: unknown = {
  coordinates: { x: 10, y: "cows", z: true }
};

console.log((itemLocation as KnownStructure).coordinates.x);
console.log((itemLocation as KnownStructure).coordinates.y);
console.log((itemLocation as KnownStructure).coordinates.z);

Instead of an error, this time around the code compiles and we get our desired output:

10
cows
true

Now, we can create a

printLocation
function that attempts to print
itemLocation
in lowercase. We know that this won't work because
itemLocation
is not a string. However, we can make TypeScript believe it is of type
string
through type assertion, allowing compilation but then throwing an error:

type KnownStructure = { coordinates: { x: any; y: any; z: any } };

let itemLocation: unknown = {
  coordinates: { x: 10, y: "cows", z: true }
};

console.log((itemLocation as KnownStructure).coordinates.x);
console.log((itemLocation as KnownStructure).coordinates.y);
console.log((itemLocation as KnownStructure).coordinates.z);

const printLocation = (loc: string) => {
  console.log(loc.toLowerCase());
};

printLocation(itemLocation as string);
  console.log(loc.toLowerCase());
                  ^
TypeError: loc.toLowerCase is not a function

Conclusion

I recommend starting to use

unknown
instead of
any
for handling responses from APIs that may have anything on the response. Having
unknown
in place forces us to check for the integrity and structure of the response and promotes defensive coding.

About Auth0

Auth0 by Okta takes a modern approach to customer identity and enables organizations to provide secure access to any application, for any user. Auth0 is a highly customizable platform that is as simple as development teams want, and as flexible as they need. Safeguarding billions of login transactions each month, Auth0 delivers convenience, privacy, and security so customers can focus on innovation. For more information, visit https://auth0.com.