I have some code which works, but keeps on throwing up TypeScript errors in my IDE. I have condensed the code down to show what my issue is.
Within a Next.JS app, I have a type (TestModel
), a method which is used to build a massive query on that model (generateTestFilter
), and further methods which handle the logic of each filter condition (handleNameFilter
). When trying to access the $and
operator within the handleNameFilter
, TypeScript doesn't like this for some reason, whereas if I try to access it in the method generateTestFilter
, there are no issues here. Any idea why this happens?
import { Filter } from "mongodb"
export interface TestModel {
name: string,
things: string[],
stuff: number
}
function generateTestFilter(tm: TestModel) {
const filter: Filter<TestModel> = { $and: [] }
handleNameFilter(tm, filter)
if (filter.$and && filter.$and.length) { // $and can be accessed here with no issues
return filter
}
return null
}
function handleNameFilter(tm: TestModel, filter: Filter<TestModel>) {
const condition: Filter<TestModel> = {name: 'Steve'}
filter.$and.push(condition) // Property '$and' does not exist on type 'Filter<TestModel>'. Property '$and' does not exist on type 'Partial<TestModel>'.ts(2339)
}
CodePudding user response:
Problem
The definition for Filter
looks like this:
/** A MongoDB filter can be some portion of the schema or a set of operators @public */
export type Filter<TSchema> =
| Partial<TSchema>
| ({
[Property in Join<NestedPaths<WithId<TSchema>, []>, '.'>]?: Condition<
PropertyType<WithId<TSchema>, Property>
>;
} & RootFilterOperators<WithId<TSchema>>);
– node-mongodb-native/src/mongo_types.ts:67-74 @ 5f37cb6
You can see it's a union of two types with the following meaning:
- All the properties on
TSchema
made optional. - A mapped type (too long to explain) intersected with the root filter operators (of which
$and
is a valid property).
Why the error in handleNameFilter
?
As the type is a union, by default, you can only access properties/methods that are valid for every member of the union. In handleNameFilter
you attempt to access $and
. This property only appears in the second half of the union but does not appear in the first half (Partial<TestModel>
). As a result, TypeScript raises an error.
Why no error in generateTestFilter
?
Previously, I've written:
As the type is a union, by default, you can only access properties/methods that are valid for every member of the union.
However you can narrow the type to one side of the union. Narrowing is giving TypeScript information to help make a type more specific (aka narrow). In generateTestFilter
you've done this here:
const filter: Filter<TestModel> = { $and: [] }
It's actually not significant that the property name in the object assigned to filter
is $and
. What is significant is that the property name is not one that appears in TestModel
. As none of the properties match the properties of TestModel
, it doesn't satisfy the first half of the union (Partial<TestModel>
) so TypeScript narrows it to the second half of the union. You can test this by changing the key/value to match properties in TestModel
– this should raise the error.
Solution
Now that the problem has been explained, we should be able to move to a solution. At first glance, it may seem the answer is to somehow narrow the type. However there's an additional problem I'd like to get to, which I think is the actual root cause.
function handleNameFilter(tm: TestModel, filter: Filter<TestModel>) {
const condition: Filter<TestModel> = {name: 'Steve'}
filter.$and.push(condition) // Property '$and' does not exist on type 'Filter<TestModel>'. Property '$and' does not exist on type 'Partial<TestModel>'.ts(2339)
}
Your handleNameFilter
function takes in an argument at the filter
parameter that is typed to Filter<TestModel>
then makes a push to the $and
property on filter
. However there's no guarantee that $and
will exist in the first place. $and
only exists because of this:
const filter: Filter<TestModel> = { $and: [] }
handleNameFilter(tm, filter)
Hypothetically, in the future, you may refactor this and remove the first line. This will break your logic. TypeScript is protecting you from this.
As a result, you'll somehow need to make $and
available to push to. The options are either:
- Enforce the existence of the
$and
property in the type of thefilter
parameter. Although, I would not recommend this because the responsibility of adding the property would be better placed within the function. Therefore: - Add the
$and
property tofilter
as part of the logic in your function.
CodePudding user response:
Besides @Wing detailed explanations, implementing the solution actually requires a bit of both "options":
- As described in point 2, make sure the
$and
operator is also initialized within yourhandleNameFilter
function (filter
argument being aFilter
, it may not have it yet) - We still need a bit of point 1 (i.e. enforcing the existence of
$and
member in parameter), because of the union inFilter
: in case the argument is a plainPartial<TestModel>
, it does not expect adding later on that$and
operator, i.e. TypeScript does not like when we "transform" the type to use the other part of the union
function handleNameFilter(
tm: TestModel,
// Force TS to accept adding later on the $and operator,
// otherwise it complains because the Partial<TSchema> part of the union
// does not expect it.
filter: Filter<TestModel> & { $and?: Filter<TestModel>[] }
) {
const condition: Filter<TestModel> = { name: 'Steve' }
// Make sure the $and operator is present and initialized
filter.$and ??= []
filter.$and.push(condition) // Okay
}