Introduction
These notes should help in better understanding TypeScript
and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples in this post are based on TypeScript 3.7.2.
Basics
When working with TypeScript, we want to rely on the defined types throughout the application.
But can we always rely on these types? There are cases where we can't guarantee that these types are valid at compile time. Let's think about some possible cases where this might apply.
When working with external files that we might load at runtime, or a simple API fetch. We can't guarantee that what we expect is actually the expected type at runtime. There might be cases where we control the complete application cycle and can assume that the expected types apply, but even then, we're dealing with an side-effects, that we want to handle safely as possible.
Let's see an example where we assume a specific type is actually valid, but where we might run into problems at runtime.
type Theme = "basic" | "advanced";
type User = {
id: number;
name: string;
points: number;
active: boolean;
settings: {
configuration: {
theme: Theme;
};
};
};
User
type defines a specific user shape, nothing too special so far. In our application we will probably be retrieving this user object via an endpoint and use this user object to later update some properties.
Somewhere in our app, we want to enable to change the user settings, to make the profile configurable. changeTheme
is a simplified function that accepts a possible theme and updates the user settings.
const changeTheme = (user: User, theme: Theme): User => {
return {
...user,
settings: {
...user.settings,
configuration: { ...user.settings.configuration, theme }
}
};
};
The user
object has to come from somewhere, so let's assume we have a function that takes care of loading that user.
const loadUser = () => {
fetch("http://localhost:3000/users/1")
.then(response => response.json())
.then((user: User) => state.saveUser(user))
.catch(error => {
console.log({ error });
});
};
Furthermore we might want to keep state of that user inside the client, so we call the saveUser
function to keep track of the object.
type State = {
user: User | undefined;
saveUser: (user: User) => void;
getUser: () => User | undefined;
};
const state: State = {
user: undefined,
saveUser: (user: User) => {
state.user = user;
},
getUser: () => {
return state.user;
}
};
TypeScript will not complain. Calling the json
function on the response object returns an Promise<any>
, so there is no guarantee that we are dealing with a User
type. The following code will compile as well. If you recall changeTheme
expects a User
type.
const user = state.getUser();
if (user) {
const result = changeTheme(user, "advanced");
}
When calling changeTheme
with the following fetch result, we will be greeted with an error at runtime. We expected the property to have the settings
key, but it actually returned setting
.
const user = {
id: 1,
name: "Test User",
points: 100,
active: true,
setting: {
configuration: {
theme: "basic"
}
}
};
changeTheme(user, "advanced");
// Error!
// Uncaught TypeError: Cannot read property 'configuration' of undefined at changeTheme
This example might be very simplified, but it should show that we can't fully rely on the data coming from an external source. We could be more defensive when working with user
, which we should be doing anyway, but it also means that we can rely on these types even less. Adding a check will prevent the previous error.
if (!user || !user.settings || !user.settings.configuration) {
return user;
}
We have seen that we need to be more restrictive when working with external data and relying TypeScript defined types, but there might be a more convenient way to handle side-effects when working inside a TypeScript codebase.
Advanced
If you have worked with Elm
or ReasonMl
before, you might have noticed that there is a way to encode and decode any JSON data. This is very valuable as we want to ensure that JSON data we are getting has the expected type. TypeScript by default doesn't offer any runtime JSON validators, but there are a couple of possible solutions to solving that problem in TS land. We will use one of these libraries to show how we can make our types more reliable.
io-ts
is a library written by Giulio Canti that is focused on runtime type validations. We will not get too deep into the details and capabilities of this library, as the official README
covers most of the basics. But it's interesting to note, that the library offers a large range of so called codecs
, which are runtime representations of specific static types. These codecs can then be composed to build larger type validations.
Codecs enable us to encode and decode in and out data and the built-in decode
method that every code exposes, returns an Either
type, which represents success (Right) and failure (Left). This enables us to decode any external data and handle success/failure 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,
points: t.number,
active: t.boolean,
settings: t.type({
configuration: t.type({
theme: t.keyof({
basic: null,
advanced: null
})
})
})
});
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>;
This gives us the exact same representation we defined in the beginning of this post.
type UserType = {
id: number;
name: string;
points: number;
active: boolean;
settings: {
configuration: {
theme: 'basic' | 'advanced';
};
};
};
To clarify the concept, let assume we have a user
object and want to validate if it has the expected shape. io-ts
also comes with fp-ts
as a peer dependency, which offers useful utility functions like isRight
or fold
. We can use the isRight
function to check if the decoded result is valid.
const userA = {
id: 1,
name: "Test User",
points: 100,
active: true,
settings: {
configuration: {
theme: "basic"
}
}
};
isRight(User.decode(userA)); // true
const userB = {
id: 1,
name: "Test User",
points: "100",
active: true,
settings: {
configuration: {
theme: "basic"
}
}
};
isRight(User.decode(userB)); // false
Now that we have some basic understanding of how io-ts
functions, let's revisit the original loadUser
function and decode the data before we pass it around inside our application. But before we start refactoring let's take a look at one more useful functionality, that will help us when working with the Either
type, that the decode returns. fold
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) => state.saveUser(user)
);
const loadUser = () => {
fetch("http://localhost:3000/users/1")
.then(response => response.json())
.then(user => resolveUser(User.decode(user)))
.catch(error => {
console.log({ error });
});
};
As we can see from the above code, 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 = () => {
fetch("http://localhost:3000/users/1")
.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 like error reporters or 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 (1)
Nice!, I miss that on TypeScript too much...,
on Nim you have run-time checks, side effects tracking, immutability, pure-functions, and JSON Types.