Types for variables that will receive async data from DB or API using --strictNullChecks in Typescript?

449 Views Asked by At

I've been running into some issues with my React + Typescript project.

This happens whenever I have to type a variable that starts of as null and will receive async data of some type.

For example: I have an AdminBlogPostPage that the admin uses to create and edit posts.

And I have a variable that will be used to keep the blogPost data.

But when that page 1st renders, there still won't be any data available, 'cause the post will be loaded async.

Currently, I'm typing it like this (I'm using --strictNullChecks):

type ADMIN_BLOGPOST_STATE = {
  blogPost: null | BLOGPOST
}

The blogPost state starts off as null and once it's loaded, it becomes BLOGPOST.

It works fine. But from this point on, everything that the admin will do to the blogPost object becomes kind of a pain, because Typescript will always be "worried" that blogPost might be null.

I'm usually using Redux in those situations, so in my reducer I'm constantly having to do type assertions make it clear to Typescript that when that action happens, blogPost will be a BLOGPOST and not null.

For example:

/* ############################# */
/* #### BLOGPOST PROPERTIES #### */
/* ############################# */

UPDATE_CATEGORY(state, action:UPDATE_CATEGORY) {
  const blogPost = state.blogPost as TYPES.BLOGPOST;   // TYPE ASSERTION HERE
  const { value } = action.payload;
  blogPost.category = value;
},
UPDATE_BOOLEAN_PROPERTY(state, action: UPDATE_BOOLEAN_PROPERTY) {
  const blogPost = state.blogPost as TYPES.BLOGPOST;   // TYPE ASSERTION HERE
  const { name, value } = action.payload;
  blogPost[name] = value;
},
UPDATE_STRING_PROPERTY(state, action: UPDATE_STRING_PROPERTY) {
  const blogPost = state.blogPost as TYPES.BLOGPOST;   // TYPE ASSERTION HERE
  const { name, value } = action.payload;
  blogPost[name] = value;
},
UPDATE_PRODUCT_CATEGORY(state, action: UPDATE_PRODUCT_CATEGORY) {
  const blogPost = state.blogPost as TYPES.BLOGPOST;   // TYPE ASSERTION HERE
  const { value } = action.payload;
  blogPost.productCategory = value;
},

How do people usually handle the types for async data? Is it worth it to start with null before the data arrives? Or is it best to start with a valid blogPost: BLOGPOST stub as the initial state so Typescript will know that blogPost will always be a BLOGPOST at all times and I won't have to deal with this repetitive type assertions anymore?

Note: Even if I decide to start off with the valid blogPost: BLOGPOST, I'll still need to load data from DB. The initial state will not be used. It will be there just to allow me to use blogPost: BLOGPOST instead of null | BLOGPOST.

5

There are 5 best solutions below

4
On

Rather than using null, I'd create a separate type to represent the state where data hasn't yet been loaded from the backend/database, along the lines of

type UnloadedBlogPost {
  stateKind: "unloaded";
}

type LoadedBlogPost {
  stateKind: "loaded";
  blogPost: BLOG_POST;
}

type BlogPostState = UnloadedBlogPost | LoadedBlogPost;

This would let you explicitly represent the UI's state before the blog post has loaded, which might be useful if you wanted to add a spinner or other loading indicator. Your reducers would still need to check for whether the blog post is loaded or not, but it'd be pretty simple to just early-exit if the blog post hasn't loaded:

UPDATE_CATEGORY(state, action:UPDATE_CATEGORY) {
  // assuming state.blogPostState is of type BlogPostState...
  if (state.blogPostState.stateKind === "unloaded") {
    return state;
  }

  // state.blogPostState is narrowed to type LoadedBlogPost, state.blogPostState.blogPost can be accessed
},

As a side note, when using Redux, you generally want your reducers to be pure functions that return a new state object instead of mutating the existing state. See the docs.

2
On

This is generally an obnoxious issue. This might not sufficiently solve your question in the way you prefer, but I will leave my answer here anyways in the hopes it may help anyone.

From my experience, I have historically handled this with one of the two following ways (one of which is framework dependent):

  • Use a compile-time type assertion that is more concise, such as the compile-time non-null assertion post-fix operator ! (instead of an explicit cast) in scenarios where it is absolutely certain that the value is non-null/non-undefined. For example, despite state.blogPost might be nullish at runtime, this will compile:

    state.blogPost![name] = value
    state.blogPost!.property = value
    

    This asserts that the state.blogPost value is non-null and it should compile fine. Note that this operator is erased during transpilation and is only for compile-time type checking. The operator should be used when it is acknowledged a value's type is potentially nullish, but it should never be at runtime. If the value is nullish at runtime, it will of course through an error. Additionally, optional chaining may also be useful in some specific scenarios.

  • For some React frameworks, such as Next.js (SSR + CSR), initial properties to render the page can be fetched from an API asynchronously and the page will not complete its render until the Promise is resolved with the props. For example, this Next.js' documentation on getInitialProps(context) covers this scenario.

    This is specific example using Next.js, but the principle idea is that the blog post would be fetched prior to injecting the component's properties and rendering, and should be non-null at runtime and compile-time.


Unrelated to your problem, keep in mind your use case for using square bracket notation x[y] and any security implications. See this GitHub article explaining potential security issues in fringe cases: https://github.com/nodesecurity/eslint-plugin-security/blob/master/docs/the-dangers-of-square-bracket-notation.md

0
On

The issue relies in that Typescript is right. There is a moment in your application where the state will be null.

What would happen if you ever try to access the state before the API call has finished? Typescript protect you from this.

You will need a lot of checks against this, but it could be a good opportunity to add loaders.

type BLOGPOST = {name: string};
type ADMIN_BLOGPOST_STATE = {
  blogPost: null | BLOGPOST
}
const state:ADMIN_BLOGPOST_STATE = {
  blogPost: null
}

// .. api calls eventually set state.blogPost to some valid value

if (state.blogPost === null) {
   return <Loader/>
} else {
   return <BlogPost id={state.blogPost.id} title={state.blogPost.title}/>
}

From your question, I'm assuming you don't want to do this, and you prefer to have a centralized check for this.

I'm also assuming you want to run the risk of some component accessing the state before it is loader. You probably have a catch-all loader while your API calls are running.

What I have made before to resolve this issue, is to initialize the state to a null value, but making Typescript believe it is valid.

Typescript Playground

type BLOGPOST = {name: string};
type ADMIN_BLOGPOST_STATE = {
  blogPost: BLOGPOST // <-- no need for the null here
}
const state:ADMIN_BLOGPOST_STATE = {
  blogPost: null! // <-- non-null assertion
};

// here, you could try to access state.blogPost.name and TS won't complain

// .. api calls eventually set state.blogPost to some valid value

// no need to check, since we assume it is always a valid value
return <BlogPost id={state.blogPost.id} title={state.blogPost.title}/>

The advantage of this, is that you keep your assertions in a single place, at the start.

The downside is that you need to be extra careful about how you access your state. You are on your own.

0
On

I didn't select any answer as correct, since they all helped me to get my head around this.

Anyway, what I've ended up doing was the following:

  • I was already controlling the UI status with a loading boolean. So before the data arrives, the only interface the user see is a <Spinner/>.

I decided to get rid of the null value, and I added a blank valid value for blogPost as the initial state. What I mean by blank is that it doesn't not have any content in it, but it has all the needed properties and it fully implements the BLOGPOST type.

Then, my type became as the following:

type ADMIN_BLOGPOST_STATE = {
  blogPost: BLOGPOST
}

This not only helped me to get rid of the type assertions in the reducer, but it also allowed me to write more complex hooks to access the state without worrying about type assertions.

export function useAdminBlogPostProperty<K extends keyof TYPES.BLOGPOST>(propName: K): TYPES.BLOGPOST[K] {
  return useSelector((state: ROOT_STATE) => state.ADMIN_BLOGPOST.blogPost[propName]);
}

To sum up, the async status will be controlled by the loading status. Hence, that is what stops the user from trying to update data that has not arrived. The fact that blogPost was null at first, was just redundant. Now I can be confident that blogPost will always be a BLOGPOST and Typescript won't go nuts when I'm writing code that assumes it.

0
On

I've just come up with a different approach to this issue:

Here is my state shape:

type ADMIN_BLOGPOST_STATE = {
  blogPost: BLOGPOST
}

I'll keep it that way. Because I know 100% sure that all my components that will access blogPost and expect BLOGPOST are only rendered after blogPost has been populated.

And to avoid having to create a dummy state to fulfill the state contract during the initial state assignment, here is what I do:

initialNullState.ts

// THIS FUNCTION ALLOWS TO PASS NULL AND STILL OBEY
// THE STATE CONTRACT. BECAUSE WE KNOW FOR SURE THAT
// BECAUSE INITIAL STATE WILL NEVER BE ACCESSED

export const initialNullState = <T extends unknown>() : T => {
  return null as T;
};

This function passes null and makes a type assertion for the type you need.

So during my initial state (on my createSlice call, since I'm using @reduxjs/toolkit, I do this:

const getInitialState = (): PAGES.ADMIN_BLOGPOST => ({
  status: { loading: true, error: null, notFound: false },
  blogPost: initialNullState(),
});

The initialNullState() call returns null, but it shapes it as BLOGPOST, so Typescript won't complain.

Of course, you have to be 100% sure that everything that calls your state, only make those calls after the pages has fully loaded and a blogPost: BLOGPOST is indeed present.