How to create a typescript custom type error and default type for it?
Asked Answered
M

1

1

I've tried to create a PojoDocument<T> generic type to guard from leaking mongoose documents out of our DALs.

My goal was to make sure the returned object is not a mongoose Document, but a POJO(PojoDocument).
I've done it by checking that T can never have the $locals property, as it's found on all documents:

// If `T` has `$locals` property, then resolve the string as the type, which will clash with `T` and show as an error. 
// So when `$locals` property not found, `T` will be returned as the type. 
export type PojoDocument<T> = T & T extends { $locals?: never }
  ? T
  : 'Please convert the document to a POJO via `.toObject()` or .lean()`';

My usage of the type looks like this:

class A {
...
  async create(dto: CreateUserDto): Promise<PojoDocument<User>> {
    const result = await this.userModel.create(dto);
    return result.toObject();
    // ^ Type 'LeanDocument<User & Document<any, any, User>>' is not assignable to type '"Please convert the document to a POJO via `.toObject()` or .lean()`"'.
  }
}

The LeanDocument<User & Document<any, any, User>> type is equals to:

interface ResultToObjectType {
  __v?: any;
  _id?: any;
  name: string;
}

Also, when I check what is returned by the create method where it's used, I get this:

Promise<"Please convert the document to a POJO via `.toObject()` or .lean()`">

This means PojoDocument<T> defaults to the "else" condition instead of the T that was provided for it.

I've tried modifying the type to include __v and _id, thinking that's the reason it doesn't consider the type as being extended by $locals?: never, as these properties don't exist on T, but it didn't change anything:

export type PojoDocument<T> = T extends { $locals?: never; __v?: any; _id?: any }
  ? T
  : 'Please convert the document to a POJO via `.toObject()` or .lean()`';

Any ideas how this check can be achieved?

Edit #1

I've added a minimal repro playground.

Moisture answered 18/6, 2023 at 11:45 Comment(1)
Please ensure you provide minimal reproducible example. Currently, your code has many things that are missing. Ideally when we put your code in our IDEs we would only see the error that you are trying to fix.Karrah
K
2

The issue is actually with UsersDal.create, where the return type is:

Promise<PojoDocument<User>>

User has only name and it doesn't meet the requirements of PojoDocument, thus you get the string error returned:

// type Test = "Please convert the document to a POJO via `.toObject()` or .lean()`"
type Test = PojoDocument<User>

However, pojoResult does obey the conditions of PojoDocument and obviously, it's not the string that is expected to be returned by the function.

Summarizing the issue:

Your create expects a promise of a literal string to be returned, not the promise object.

I'm not familiar with mongoose, but I can suggest changing the return type of the create to:

Promise<PojoDocument<LeanDocument<User & Document<any, any>>>>

Full code:

class UsersDal {
  constructor(private userModel: Model<User>) {}

  async create(createDto: UserDto): Promise<PojoDocument<LeanDocument<User & Document<any, any>>>> {
    const result = await this.userModel.create(createDto);

    const pojoResult = result.toObject();
    return pojoResult; // no error
  }
}

class UsersService {
  constructor(private usersDal: UsersDal) {}

  async create(createDto: UserDto): Promise<User> {
    const result = await this.usersDal.create(createDto);
    return result; // no error
  }
}

playground

Karrah answered 21/6, 2023 at 13:6 Comment(8)
type PojoDocument<T> = T extends { $locals?: never; } still leads to User being the string error although { name: string } does satisfy it. Why?Moisture
It doesn't. Check here. Even if the field has a type of never, it still should exist, which is not the case with UserKarrah
So by that logic, I can reverse the results, and do this: type PojoDocument<T> = T extends { $locals: string | number | boolean | Record<any, any>; } ? "Error" : T; but then result, which is User & Document<any, any>, which does have $locals property - still satisfies the condition. Why?Moisture
It doesn't satisfy the condition. You are returning T if doesn't satisfy the condition, and if it does then you return an error. playgroundKarrah
Thank you for taking the time to explain, I should've sent this playground as an example. Note that the UsersDal.create returns result(User & Document) which has $locals property on it. So that would satisfy the conditional type to return error string and the object does not satisfy error string, so I'd expect a type error there. But it doesn't show an error. That's my issue all along, I want to make sure that I'm returning an object that is stripped of all document properties.Moisture
Your function expects Promise<User> to be returned, since User doesn't satisfy the conditional type. result is a subset of User: User & Document<any, any>. You don't make PojoDocument checks on it, thus you don't get the error. You can do something like thisKarrah
Have you tried simplifying the type to export type PojoDocument<T> = T extends { $locals: any } ? 'Error' : T; to make sure it's not a type narrowing issue with the more specific $locals type.Blanco
Yes but unfortunately that won't help, as the conditional return type is resolved before the result variable is making a check against it. So the conditional type is resolving to User and result: User & Document<any, any> is passing. @Karrah suggested using a wrap function where the returned value is asserted with the generic, forcing the type system to apply the conditional type against the type of result. While that works and helped me understand the type system better, it's a little clunky, and I'm still hoping to find a better solution.Moisture

© 2022 - 2025 — McMap. All rights reserved.