A few weeks ago, I've never heard of or even had any idea about Conditional Type, Generic Constraints, or any advanced usage of TypeScript. I've used TypeScript before but it's only a matter of adding type definition for primitive values and simple objects.
Before we get started, let me preface this by saying that I prefer untyped JS whenever possible. I understand the benefit of using a type system in more complex projects, but using it everywhere is just overkill.
This post is kind of follow up for my previous one about the new web framework we developed in Traveloka. I think it's worth having a separate post to explain how we're using Typescript in the new framework as well as instances where using this helps us ship code in confidence.
Non-null null
We use React Context a lot in this new codebase, this is partly what enables good composition between different modules in our framework. It's usually written like this:
import React from 'react';const SomeContext = React .createContext <SomeType >('');function useSomeContext (): string { return React .useContext (SomeContext );}
As you know, we have to provide initialValue
, but sometimes data for this context comes from the backend; it's not possible to us to provide correct initial value other than null
or undefined
. The downside of using these as the initial value is you ended up writing a lot more code:
import React from 'react';const SomeContext = React .createContext <SomeType | null>(null);function useSomeContext (): SomeType { const value = React .useContext (SomeContext ); if (value === null) { // do something } return value ;}
Instead of writing it like that, We set null
as initial value with additional non-null assertion operator. Basically, we tell TypeScript compiler that this value is null, but it's not.
import React from 'react';const SomeContext = React .createContext <SomeType >(null!);function useSomeContext (): SomeType { return React .useContext (SomeContext );}
Madness, I know.
The only way we're able to do this is because we own our framework, end-to-end. Any context consumer is guaranteed to be rendered inside the correct provider. Without this guarantee, this trick won't work, so keep in mind.
Generic Constraints
In TypeScript, you can use generics to create a reusable function that operates on various types. Sometimes, you want to limit the possible value of said type, like we did when we're creating a function to access Next.js private property. To achieve this we're using TypeScript feature called Generic Constraints
We want to create a function called getFromAppContext
to read specific properties of __NEXT_DATA__
in the browser. We use this function to reduce API calls due to our usage of getInitialProps
. In the server, we fetch the data and hydrate it on the client. When doing client navigation, instead of re-fetching the same data, we can simply read those hydrated data.
declare const __NEXT_DATA__ : { props : { appContext : AppContext ; };};function getFromAppContext <T extends keyof AppContext >(property : T ) { return __NEXT_DATA__ .props .appContext [property ];}
If you're wondering why we can't just use keyof AppContext
as argument type for property
, it is because the return value you'll get is union type instead of the exact appContext property that you want to access. See this typescript playground for more details.
With this, we're able to create a function that throws an error on compile time when accessing property that doesn't exist. As a bonus, we get autocompletion when writing code using getFromAppContext
function listing all possible variants that we can choose.
This concept here, is also the foundation for more complex types, as you'll see in the next section.
Conditional Types && Mapped Types
We have a translation API that's stored similar to how NoSQL store object. It has few tables, identified by name, and fields that can be added or removed as needed. The only way to know whether those fields exist is to check the dashboard (or call the API and see the return value). You can think of Record<string, Record<string, string>>
as the object representation in TypeScript.
We want to create react hooks-based API to fetch these data using technique similar to GraphQL, we define fields that we want to fetch:
import React from 'react';function Component () { const cr = useContentResource ({ GeneralLayout : { logIn : '', }, }); return <div >{cr .GeneralLayout .logIn }</div >;}
That's the gist of our API, we want the user to pass any object to our function and they'll get back the same object. If this is the only requirement, we can simply get away with using Generic. The reality is there's another use case where instead of fetching a single field, we want to be able to fetch any fields available given content resource name.
function Component () { const cr = useContentResource ({ GeneralLayout : { logIn : '', }, SimpleSentences : {}, }); // we can access anything here without compile error cr .SimpleSentences .popular ; cr .SimpleSentences .random ; // as well as other object defined in the same query cr .GeneralLayout .logIn ; // but any fields not defined shoud trigger compile time error cr .GeneralLayout .register ;Property 'register' does not exist on type '{ logIn: string; }'. cr .GeneralLayout .welcome ;Property 'welcome' does not exist on type '{ logIn: string; }'.}
This means we have to provide different type based on whether the user provides an empty object or object with a predefined set of keys. This is the perfect use case for Conditional Types. First, we know that we need to start with generic because our query shape can be anything. We'll add constraints to make sure that the query passed is indeed a Record
. We also know that the result is somehow related to the original query, so we need to create Generic Types for the result type.
type ContentRequest = Record <string, Record <string, string>>;declare function useContentResource <T extends ContentRequest >( query : T ): ContentResult <T >;
To create type definition for Result<T>
we need to look into our original requirement, it returns the same shape if the inner object has fields defined, and it returns Record<string, string>
if it's an empty object. How do you check for an empty object in TypeScript?
Turns out, you can do it by using Record<string, never>
!
First, we know that the return value will be an object with the same keys. This means the return type is a Mapped Types where we can use keyof T
for the key of our fields. Then, the value for that field depends on the inner object that passed. We use Conditional Types here to check for empty object and return Record<string, string>
for this. If the user passes a non-empty object, we return the type of that object.
type EmptyObject = Record <string, never>;type ContentResult <T extends ContentRequest > = { [k in keyof T ]: T [k ] extends EmptyObject ? Record <string, string> : T [k ];};
Click here to play around with the final result.
Well-placed @ts-ignore
Another API that we need to create is to fetch a list of images. It's similar to content resource API above with 1 major difference. Instead of having a free-form field, there's a strict schema for image objects, so the type is known in advance. We also want to add performance optimization by only hydrating fields that the user requested, despite our API returns the whole object.
import React from 'react';function Component () { const sl = useImageSlider ({ HomeUSP : { key : '', alt : '', link : '', }, }); return sl .HomeUSP .map ((image ) => { return <img key ={image .key } src ={image .link } alt ={image .alt } />; });}
From the above example, we only hydrate key
, alt
, and link
fields, even though our API returns a whole lot more. We can do that by using the shape of a requested object using generic, but we faced an issue when we have type definition like this:
type Image = { key : string; alt : string; link : string; caption : string | null;};type SliderRequest = Record <string, Partial <Image >>;declare function useImageSlider <T extends SliderRequest >( query : T ): SliderResult <T >;
In the type definition above, caption
is a nullable field, but our hooks API expects the user to opt into a field by providing an empty string. When you only reuse the generic as return type, the type definition for caption
field will become string
instead of string | null
. This is not what we wanted because it might lead to bugs from users forgetting to do null checking before using nullable fields.
To fix that, we're going to force the return type to use Image
type, but we only pick fields that are requested. We'll introduce a new Generic Type called FilterKeys
which creates Mapped Type from U
with type definition from T
.
type FilterKeys<T, U> = keyof U extends keyof T ? Pick<T, keyof U> : never;// And we use it like thistype SliderResult<T> = { [k in T]: Array<FilterKeys<Image, T[k]>> };
Because we use extends
keyword, TypeScript will show error on Pick<T, keyof U>
because there might be some property of U
that doesn't exist on T
. The thing is, we already give type constraint on our query shape to extends SliderRequest
, so the inner fields are constrained by Partial<Image>
type.
Check out this TypeScript playground to see that indeed, it works.
To be honest, I'm still not sure whether it works because I'm exploiting a bug in TypeScript itself or not. For all I know, it's not a bug, it's a feature.
The last thing we need is to silence the only remaining error in our type definition:
type FilterKeys <T , U > = keyof U extends keyof T ? // prettier-ignore // @ts-ignore Pick <T , keyof U > : never;
And our type is still working as expected, even though have // @ts-ignore
in place.
It would be better if we have invalid
proposal in TypeScript. Instead of returning never
, we can give a better error message when the user uses our hooks API in the wrong way. I can't wait to see this proposal implemented in TypeScript,
Knowing that this framework project is a complex one, I'm glad we decided to use TypeScript from the start. In development, there have been few rewrites of the internal implementation of the framework, and having a type system already set up in place helps us make sure our public API is stable enough despite rewrites.
In our framework, we have configured webpack in such a way that server and client can import different files at build time (with server import as the default one). When we create client-specific modules by using .client.ts
extension, this module is never imported anywhere (except our tests). By having shared type definition, we can create different implementation detail for server and client, yet still having consistent API.
This is one of the few cases where investing in TypeScript from the start yield visible benefits. It's possible to implement the new framework without type, but every change will need to be thoroughly tested. Type system provides us with the first layer of confidence when shipping code, we just have to learn to maximize it.