What happens when we run this Typescript code?
type Person = {
Name: string;
Sport: string;
}
async function loadPerson(id: number): Promise<Person> {
let result = await fetch(`/person/${id}.json`);
return await result.json() as Person;
}
loadPerson(1).then(person => console.log(person.Name));
person/1.json
{
"Name": "Ortho Stice",
"Sport": "Tennis"
}
Exactly what you think - it logs Ortho Stice to the console. Do you think the code is type safe? What do you think as Person
is doing? If you read the code as you would C# or Java then you might think the results of result.json()
is being cast to type Person
, and that the value of result.json()
is being checked to see that it adheres to type Person
. But that’s not true. Typescript checks types at compile time, not run time. The as Person
is us, the programmer, telling the Typescript compiler that result.json()
absolutely, 100%, will always return data that adheres to the type Person
. This is type assertion is useful if we were gradually converting a Javascript project to Typescript, but is incredibly reckless if we’re telling the compiler about the shape of data remote APIs will return. If you were a new developer to this project and looked at the type annotation of the function you’d assume some checking of the data was taking place.
The above Typescript code compiles to the following Javascript.
"use strict";
async function loadPerson(id) {
let result = await fetch(`/person/${id}.json`);
return await result.json();
}
loadPerson(1).then(person => console.log(person.Name));
Now what Typescript does (or doesn’t do) is clear. The types are erased at compile time and we’re just left crossing our fingers that the JSON object returned from the server has an attribute called Name
. Not a good look for a production application. The code would run just fine against the following JSON file. Let’s address this with a decoder.
{
"Name": "Enfield Tennis Academy",
"Location": "Boston"
}
Decoders
Decoders provide a safe way to turn interpret some generic object (such as the JSON response of an API) into a solid implementation of one of your application types. It’s a common pattern in programming languages with strong type systems such as Elm and F# where the compiler won’t let you do the as Person
shenanigans Typescript does. There’s very little additional code required and it’s a nice way to do object validation. We’ll be using the Typescript library ts.data.json, I’m sure others exist. Along side your application types you’ll define a decoder.
type Person = {
Name: string;
Sport: string;
}
const PersonDecoder = JsonDecoder.object<Person>(
{
Name: JsonDecoder.string,
Sport: JsonDecoder.string
},
"PersonDecoder"
);
There’s some extra cool stuff happening that’s worth pointing out. By passing in the generic type Person
we are not only declaring that the decoder will return data which adheres to Person
, but it also checks at compile time to make sure that all attributes we define in the decoder (where the values are JsonDecoder.string
) actually match up with the attributes in our Person type
. The attributes must match 1:1 or else your code won’t compile and you’ll see red squiggles in your IDE. This is a great feature which means your application type and decoder always stay in sync.
To put the decoder into use we add a final step in the response.
async function safeLoadPerson(id: number): Promise<Person> {
let result = await fetch(`/person/${id}.json`);
let data = await result.json();
return await PersonDecoder.decodePromise(data);
}
safeLoadPerson(1).then(person => console.log(person.Name));
If the JSON returned by the server does not match what’s defined in the decoder then we get a nice error message thrown describing the missing key, key present that we didn’t expect, or mismatching types.
<PersonDecoder> decoder failed at key "Sport" with error: undefined is not a valid string
Safe Fetch
We can write a generic wrapper around fetch which lets us explicitly define the type of data we expect the call to return (for the compiler), and a decoder which will decode data to that type.
type Route = string;
async function request<T>(uri: Route, decoder: JsonDecoder.Decoder<T>): Promise<T> {
let response = await fetch(uri);
let jsonResponse = await response.json();
return await decoder.decodePromise(jsonResponse);
}
let uri: Route = "/person/1.json";
request<Person>(uri, PersonDecoder).then(person => console.log(person.Name));
Now we have a function request where:
- The data type request returns is checked at compile time.
- There is a decoder which explicitly states how to convert some generic JSON object into this type.
- At run time the JSON returned from the API is run through the decoder to check adherence to this type.
- The decoder stays in sync with our types through compile time checks.
Pretty good! This is a quick example showing how you can use types and decoders to get both compile and run time safety. You’d likely want to add capability to handle errors, for when the remote API returns a different JSON object describing the error. This can be achieved through the oneOf decoder, where decoders get applied in order and the first one that succeeds gets returned.
const PersonOrErrorDecoder = JsonDecoder.oneOf<PersonDecoder, GenericErrorDecoder>(
[JsonDecoder.object<PersonDecoder>, JsonDecoder.object<GenericErrorDecoder>],
"PersonOrErrorDecoder"
);
Further reading
- Structural Typing in Typescript - if you use Typescript and don’t know what structural typing is you should probably read this.
- Typescript FAQ - describes a lot of the quirks of Typescript and how it differs to other language type systems.
- Purify - functional programming library for Typescript.
- ts.data.json - decoder library for Typescript.