Introduction
These notes should help in better understanding advanced TypeScript
topics and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples are based on TypeScript 4.6.
Note: This post is an update version of the original Notes on TypeScript: Handling Side-Effects
Basic
There are situations when working with TypeScript, where we can't guarantee that the types reflect the actual data we are working with. Examples for these types of situations include reading from a file, fetching data from an external endpoint or loading information saved in local storage. In all of the above scenarios we can't guarantee that the data entering our application actually reflects the types we defined. Further more, in any of these scenarios we can be running into runtime errors, no matter what the type actually claims.
This means once we're dealing with external data, that is not defined at compile time, we need some mechanism to safely handle this type of data.
To make it more practical, let's assume the following situation: we want to load a user
from a pre-defined endpoint.
const loadUser = (id: number) => {
fetch(`http://www.your-defined-endpoint.com/users/${id}`)
.then((response) => response.json())
.then((user: User) => saveUser(user))
.catch((error) => {
console.log({ error });
});
};
At first look this all sounds reasonable, we fetch a user by id, and then save the data for further processing. If you take a closer look at the code, you will notice that we defined the data to be of type User
after decoding the json data. The User
type in this example is defined as follows:
type User = {
id: number;
name: string;
active: boolean;
profile: {
activatedAt: number;
};
};
Interestingly the code will compile and TypeScript will show no errors as we defined a User
and claimed that the response, once decoded, will always be of aforementioned type. Even more interesting is the fact that calling the json function on the response object returns an Promise<any>
, so there is no actual guarantee that we are dealing with a User
type at runtime.
Let's see a scenario where our assumptions might fail, so let's add a saveUser
function, that expects a user with some profile information:
const saveUser = (user: User) => {
const activationDate = user.profile.activatedAt;
// do something with the information...
};
Now how can our application break? The code above will compile, but what happens when the returned user object doesn't have any profile information? Let's assume that at runtime, we suddenly receive the following object:
{
id: 1,
name: "Some User Name",
active: true,
extended: {
activatedAt: 1640995200000
}
};
The result will still be a User
inside our application, but we will run into an error at runtime, as soon as we call the saveUser
function. One way to deal with this, is to get more defensive, by exteding our function to check if the property profile
even exists:
const saveUser = (user: User) => {
if (user && user.profile && user.profile.activatedAt) {
const activationDate = user.profile.activatedAt;
// do something with the information...
} else {
// do something else
}
};
But this will quickly become complicated when we have to do these checks all over our application when working with external data. Rather, we want to do this check as early as possible, in fact at the moment we have access to said data.
Advanced
TypeScript doesn't offer any runtime JSON validation capabilities, but there are libraries in the TypeScript eco-system that we can leverage for that specific case.
We will use the popular io-ts
library to ensure the data we are working on is reliable throught the application. Our approach will be to decode any external data entering our application.
io-ts
is written by Giulio Canti and offers runtime type validations. For more information on io-ts consult the README
. So called codecs
are used to encode/decode data.These codecs are runtime representations of specific static types and can be composed to build even larger type validations.
Codecs enable us to encode and decode any in/out data and the built-in decode
method returns an Either
type, which represents success (Right) and failure (Left). Via leveraging this functionality we can decode external data and handle the success/failure case specifically. To get a better understanding let's rebuild our previous example using the io-ts
library.
import * as t from "io-ts";
const User = t.type({
id: t.number,
name: t.string,
active: t.boolean,
profile: t.type({
activatedAt: t.number,
}),
});
By combing different codecs like string
or number
we can construct a User
runtime type, that we can use for validating any incoming user
data.
The previous basic construct has the same shape as the User
type we defined previously. What we don't want though, is to redefine the User
as a static type as well. io-ts
can help us here, by offering TypeOf
which enables user land to generate a static representation of the constructed User
.
type UserType = t.TypeOf<typeof User>;
Interestingly this will give us the same representation we defined in the beginning:
type UserType = {
id: number,
name: string,
active: boolean,
profile: {
activatedAt: number,
},
};
Once we have a defined shape, we can verify if the data is of that expected shape and either handle the success or failure case:
const userA = {
id: 1,
name: "Test User A",
active: true,
profile: {
activatedAt: t.number,
},
};
const result = User.decode(userA);
if (result._tag === "Right") {
// handle the success case
// access the data
result.right;
} else {
// handle the failure
}
The result of the decode function contains a _tag
property that can either be a Right
or Left
string, which represent success or failure. Furthermore we have access to a right
and left
property, containing the decoded data in the success case (right) or an error message in the failure case (right).
The above example can be extended to use a so called PathReporter
for error message handling:
import { PathReporter } from "io-ts/lib/PathReporter";
if (result._tag === "Right") {
// handle the success case
// access the data
result.right;
} else {
// handle the failure
console.warn(PathReporter.report(result).join("\n"));
}
io-ts
also comes with fp-ts
as a peer dependency, which offers useful utility functions like isRight
or fold
. We can use the the isRight
function to check if the decoded result is valid, instead of having to manually handle this via the _tag
property.
import * as t from "io-ts";
import { isRight } from "fp-ts/lib/Either";
const userA = {
id: 1,
name: "Test User A",
active: true,
profile: {
activatedAt: t.number,
},
};
isRight(User.decode(userA)); // true
const userB = {
id: 1,
name: "Test User",
active: true,
extended: {
activatedAt: t.number,
},
};
isRight(User.decode(userB)); // false
One more useful functionality that will help us when working with the Either
type, that the decode returns is fold
, which enables us to define a success and failure path, check the following example for more clarification:
const validate = fold(
(error) => console.log({ error }),
(result) => console.log({ result })
);
// success case
validate(User.decode(userA));
// failure case
validate(User.decode(userB));
Using fold
enables us to handle valid or invalid data when calling our fetch functionality. The loadUser
function could now be refactored to handle these cases.
const resolveUser = fold(
(errors: t.Errors) => {
throw new Error(`${errors.length} errors found!`);
},
(user: User) => saveUser(user)
);
const loadUser = (id: number) => {
fetch(`http://www.your-defined-endpoint.com/users/${id}`)
.then((response) => response.json())
.then((user) => resolveUser(User.decode(user)))
.catch((error) => {
console.log({ error });
});
};
We might handle any incorrect representation by throwing another error. This prevents the data from being passed around in our application. There are more improvement we can make here. Right now, we're being very specific in how we're handling the User
decoding. There might be an opportunity to write a general function that handles any promise based data.
const decodePromise = <I, O>(type: t.Decoder<I, O>, value: I): Promise<O> => {
return (
fold < t.Errors,
O,
Promise <
O >>
((errors) => Promise.reject(errors),
(result) => Promise.resolve(result))(type.decode(value))
);
};
Our decodePromise
function handles any input data based on a defined decoder and then returns a promise, based on running the actual decoding operation.
const loadUser = (id: number) => {
fetch(`http://www.your-defined-endpoint.com/users/${id}`)
.then((response) => response.json())
.then((user) => decodePromise(User, user))
.then((user: User) => state.saveUser(user))
.catch((error) => {
console.log({ error });
});
};
There are more improvements we could make, but we should have a basic understanding of why it might be useful to validate any external data at runtime. io-ts
offers more features handling recursive and optional types. Furthermore there are libraries like io-ts-promise
that provide more features and useful helpers, the above decodePromise
, for example, is available in a more advanced variant via io-ts-promise
.
Links
If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif
Top comments (0)