#How to effectively utilize discriminator types in a NestJS module?

41 messages · Page 1 of 1 (latest)

green surge
#

Hello! I'm currently developing a NestJS module aimed at storing notifications of various kinds within the same table by utilizing discriminators. Despite eliminating the use of TypeScript's as keyword to keep things straightforward and rely on type inference, I'm encountering difficulties in achieving the desired type narrowing and inference with Mongoose's functionality.

Although Mongoose's functionality seems well-designed for typing, I'm facing challenges in making type inference work as expected. I'm hoping to receive some guidance on how to effectively manage discriminator types in my code.

I'll provide the content of the file along with links to StackBlitz and GitHub - notification.service.ts for reference. Any insights or solutions would be greatly appreciated!

GitHub

Created with StackBlitz ⚡️. Contribute to victor-shelepen/nestjs-typescript-starter-lms6zu development by creating an account on GitHub.

#
@Injectable()
export class NotificationService {
  constructor(
    @InjectModel(Notification.name)
    private notificationModel: Model<TNotification>,
    @InjectModel('TowLogChange')
    private towLogChangeModel: Model<ITowLogChangeNotification>,
    @InjectModel('SampleAssigned')
    private sampleAssignedModel: Model<ISampleAssignedNotification>,
  ) {}

  async testA() {
    const doc = await this.createA({
      userId: new Types.ObjectId(),
      type: 'TowLogChange',
      metadata: {
        towId: new Types.ObjectId(),
        newStatus: 'some status',
      },
    } as ITowLogChangeNotification);
    console.log(doc.metadata.towId);
  }

  async createA<T extends TNotification>(notification: T) {
    return this.notificationModel.create(notification) as Promise<
      HydratedDocument<T>
    >;
  }
#
    const doc = await this.createB({
      userId: new Types.ObjectId(),
      type: 'TowLogChange',
      metadata: {
        towId: new Types.ObjectId(),
        newStatus: 'some status',
      },
    } as ITowLogChangeNotification);

    // towId can not be found.
    // console.log(doc.metadata.towId);

    // Too complicated.
    // Type is not inferred as in variant A
    console.log(
      (doc as HydratedDocument<ITowLogChangeNotification>).metadata.towId,
    );
  }

  async createB<T extends TNotification>(notification: T) {
    if (notification.type == 'TowLogChange') {
      return this.towLogChangeModel.create(notification);
    } else if (notification.type == 'SampleAssigned') {
      return this.sampleAssignedModel.create(notification);
    }

    throw new Error('Unpredictable logic place.');
  }```
clear cipher
#

First question that comes to mind is, why are you creating all these interfaces? They shouldn't be needed.

#

Second question, how can your sample-assigned schema be discriminated, when it has no property for the discriminator key?

#

Third question, what are you trying to accomplish with the createNotficationDiscriminator factory? Makes no sense to me at all.

I'll wait for the answers to those questions, before I continue, because I can hardly follow anything you are doing or rather what your intentions are.

green surge
#

The first. This is a real project example. In the project where I work, we widely use interfaces almost everywhere. Interfaces can be a problem but they are not our problem. I've checked twice. We can simplify an example or use another. I also played with a simplified typescript solution.

The second the sample-assigned is like inherited collection from Notification. The type field is a discriminator. The project is operating.
But it has a type of hack that I describe in the issue description.

The third. I agree that the factory moves us aside. For me, it helps to generate the inherited schemas without duplications.
I agree to refuse it.

I see that it would be great to reuse the documentation example for discussion.
I am ready to create a documentation-based example to show my problem. In my scenarios, I just create different instances I try to keep the type consistent. This part is missing from the documentation.

#

This is a simplified version of the problem

type TLetterA = 'a';
type TLetterB = 'b';

type TLetters = TLetterA | TLetterB;

const letterA: TLetterA = 'a';
const letterB: TLetterB = 'b';
const letterAorB: TLetters = letterA;

function passLetter<T extends TLetters>(letter: T): T {
    if (letter == 'a') {
        return letter
    } else if (letter == 'b') {
        return letter;
    }

    throw new Error('Some error....');
}

const passLetterA = passLetter(letterA);
//   ^?
#

I guess the function declaration overloading will help for this reason. But the type generic variant looks elegant that I am working on.

type TLetters = 'a' | 'b' | 'c';

function createLetter(letter: 'a'): Promise<'a'>;
function createLetter(letter: 'b'): Promise<'b'>;
function createLetter(letter: 'c'): Promise<'c'>;
function createLetter(letter: TLetters): Promise<TLetters> {
    if (letter === 'a')
        return Promise.resolve('a');
    else if (letter === 'b')
        return Promise.resolve('b');
    else if (letter === 'c')
        return Promise.resolve('c');

    throw new Error('Some error...');
}

const letterA = createLetter('a'); // letterA is Promise<'a'>
const letterB = createLetter('b'); // letterA is Promise<'b'>
const letterC = createLetter('c'); // letterA is Promise<'c'>

https://www.typescriptlang.org/play?#code/C4TwDgpgBAKgMhYwICcDOUC8UDkBDHKAH1wCNCScBjHAbgCh6AzAVwDsrgBLAezaiooIeZAiSoAFABtEyFAC5cBAJSKACih4BbLmggAefDgB8DVh258BQkRDFzps1IpzlVUDdt0HXJs+05efkFhUScUR3EFXBp3Tx09QxpTZgDLYJswqMi5RXhwtDjNBIN8qLRjKABveig6qC4mKBzULExsI2Va+p6hYBYUfnjvADohNB4pADcICU6GHogpPQamlpQ2jrdunrq+gaHi0fHJmbm3BfqllcbmmSjNmJwu3fr9wY8jvTGICenZ6jPBg7KDAAAWmgA7lA2BBoQBRFCaCI4ADK2mgqGRIxxQPoAF9GFQ+GhgFB7nIAIJYayhOzhOYqWhQAD0LPJ4Wpuk+XkSRmM9GJbFJHKiACEaSFbPZJL5lMy2aKqQ0MMM+eQBUKRRTUABhSWZenZQHy1nsnUoLmqr4+ZJAA

#

Thank you for your interest in my problem. 🙂

clear cipher
#

Ok. So, your challenge is getting TypeScript to infer everything. But, type inference doesn't always work. Right? When in your code does the inference not work? That isn't clear to me?

green surge
#

Right. I would like just this code to work. I've removed as operator in the createA method.


  async createA<T extends TNotification>(notification: T) {
    return this.notificationModel.create(notification)
  }

  async testA() {
    const doc = await this.createA({
      userId: new Types.ObjectId(),
      type: 'TowLogChange',
      metadata: {
        towId: new Types.ObjectId(),
        newStatus: 'some status',
      },
    } as ITowLogChangeNotification);
    console.log(doc.metadata.towId);
  }
#

I tried to narrow in variant B.

clear cipher
#

What is defining the metadata sub-document?

green surge
#

I receive an error.

#

Subdocoments located in the metadata field.

#

These are my varaint how I tries to create a contravariant documents.

clear cipher
green surge
#

But it is the same. It is a sandbox.

clear cipher
#

You have a factory function for createNotificationDiscriminator and it receives a TSchema<TMetadata> type, but that is never shown in the code.

#

Ok. Nevermind. I see what is going on.

#

So, you are trying to also discern the metadata type between discriminators via the factory? That won't work AFAIK. TypeScript will definitely get lost.

green surge
#

It works.

clear cipher
#

Your screenshot says otherwise from a TypeScript perspective.

green surge
#

We can rid of factory to simplify the working example.

clear cipher
#

But, this is where you are heading though, isn't it?

#

What is the purpose of the factory with the TSchema generic?

green surge
#

This has to narrow and help to infer the type of a specified interface, not union.

#

I agree that the example is convulated. I can not find a reason why the type is not narrowed.

#

I receive an error Type 'Document<unknown, {}, ITowLogChangeNotification> & ITowLogChangeNotification & { _id: ObjectId; }' is not assignable to type 'HydratedDocument<T>'

clear cipher
#

Yeah, so when you are doing this kind of TypeScript, it no longer has anything to do with type inference. I think you are misunderstanding what that means. Type inference is when the compiler can deduce and assign a type. You are looking to get the right assignment through your type definitions. They are different.

green surge
#

But the left side is generic from HydratedDocument<ITowLogChangeNotification>

#

Why are they not equal? HydratedDocument<ITowLogChangeNotification> and HydratedDocument<T>

#

Yes.

clear cipher
#

I think you should back up. Remove all the interfaces, as they aren't necessary and try to work with the basics to get everything working from a data structure standpoint. Then go from there. I can't help you with all this to be honest, as it is simply confusing to me.

green surge
#

Ok. I see. I will remake the example from the documentation. Thank you.