I am using the Directus API with Typescript. With Typescript, its API calling functions return partial entities (eg. PartialItem<Book>
), so I'm diligently checking the existence of required properties before passing the data onward.
However, I'm still getting Typescript type errors which are unrelated to Directus specifically but perhaps with how I'm handling the partial type.
Here's a simplified example with pure Typescript (and on TS Playground):
interface Book {
id: string;
title?: string;
author?: string;
pages?: number;
}
class Library {
static books: Book[] = [];
static addBook(data: Partial<Book> & {id: string}) {
this.books.push(data);
}
}
function fetchBooks(): Promise<Partial<Book>[]> {
return new Promise((resolve)=>{
setTimeout(()=>{
resolve([
{id: "1234",title:"Nineteen Eighty-Four"},
{id: "2345", title: "The Great Gatsby", author: "F. Scott Fitzgerald"}
])
},1000)
})
}
function bookLoadingFunction() {
fetchBooks().then(books=>{
books.map(b=>{
if(!b.id){
// Handle the fact that the API is missing ID
}else{
Library.addBook(b); // * COMPILE ERROR HERE *
}
})
})
}
Even after checking, the compiler doesn't appear to be able to infer that b.id
is defined. And I get the following error:
Argument of type 'Partial<Book>' is not assignable to parameter of type 'Partial<Book> & { id: string; }'.
Type 'Partial<Book>' is not assignable to type '{ id: string; }'.
Types of property 'id' are incompatible.
Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
Is this a limitation of the compiler, or does there exist an edge case where b.id
could indeed still be undefined
? And is there a way to keep the compiler happy without losing type safety?
I know the following would make the errors go away, but it's far from ideal:
Library.addBook(b as Partial<Book> & {id: string});
Thanks.
You were expecting that by checking the id
property of a Partial<Book>
object for truthiness, you would get the compiler narrow the object from Partial<Book>
to Partial<Book> & {id: string}
. Unfortunately this is not how narrowing works in TypeScript. See microsoft/TypeScript#16976 for a (longstanding) open feature request to support this sort of thing.
Currently if you check the value of a property like b.id
, it will only narrow the type of the property b.id
itself, and not the type of the containing object b
... well, unless b
is of a discriminated union type and id
is its discriminant property. But Partial<Book>
is not a union at all, let alone a discriminated one. Oh well.
Here are the workarounds I can think of. One is to reassemble your object from the narrowed property and the rest of the object, via something like object spreading:
if (!b.id) { } else {
const obj = { ...b, id: b.id };
/* const obj: {
id: string;
title?: string | undefined;
author?: string | undefined;
pages?: number | undefined;
} */
Library.addBook({ ...b, id: b.id }); // okay
}
You can see that obj
is seen to be of a type equivalent to Partial<Book> & {id: string}
(a.k.a. Book
). And therefore you can call Library.addBook(obj)
without error. So you've given up on narrowing b
, and are instead building a new version of b
of an already-narrowed type.
If you don't want to create a new object which is essentially equivalent to the old one, you could give up on checking if (!b.id) {}
and instead write a user-defined type function that takes b
as an input and returns a type predicate saying that b
can be narrowed depending on whether the result is true
or false
. For example:
function hasId<T extends { id?: any }>(
x: T
): x is T & Required<Pick<T, "id">> {
return x.id !== undefined
}
The hasId()
function accepts a parameter x
which is known to have an id
property (or at least an optional one), and returns the type predicate x is T & Required<Pick<T, "id">>
. You can see it in action:
if (!hasId(b)) { } else {
b // b: Partial<Book> & Required<Pick<Partial<Book>, "id">>
Library.addBook(b); // okay
}
In the else
clause, hasId(b)
has returned true
, meaning that b
has been narrowed to Partial<Book> & Required<Pick<Partial<Book>, "id">>
. That's also equivalent to Book
(the type Required<Pick<Partial<Book>, "id">>
is ugly, written in terms of the Required<T>
, Pick<T, K>
and Partial<T>
utility types, but if you go through it you'll see it is equivalent to {id: string}
).
So here you've decided to tell the compiler how to narrow b
, since it doesn't know how to do so itself.