mswjs / data

Data modeling and relation library for testing JavaScript applications.
https://npm.im/@mswjs/data
MIT License
819 stars 52 forks source link

Expose helper types to get `Entity` and `Value` #185

Open christoph-fricke opened 2 years ago

christoph-fricke commented 2 years ago

As far as I am aware this library only exposes types for the exported functions. This makes it hard to type use-cases that go beyond "creating a few models and query them".

My example use-case are mappers to map the models to DTOs, which are returned by an MSW API. These mappers mostly convert relations to their ids and attaches computed information. E.g. user: oneOf("user") is mapped to userId: string. Each mapper should take an Entity (or maybe Value 🤔) so it kinda look like this:

function mapToDTO(entity: ThisIsHardToType): DTO {
// ...
}

My current approach to type the parameter is the following helper type. However, I actually run into problems when I want to map relations on an entity as well since the mock API requires it, which are of type Value. I have no idea how should be able to extract the Value to use it instead of Entity in a similar fashion. Is this a valid use-case or am I totally over-complicating things?

export type DB = ReturnType<typeof createDB>;

export type GetEntity<Key extends keyof DB> = ReturnType<DB[Key]["create"]>;
kettanaito commented 2 years ago

Hey, @christoph-fricke. This is a great suggestion, we should export those types to allow developers to annotate their logic safer.

Would you be interested in opening a pull request? I will help you with the code review and getting these changes released.

christoph-fricke commented 2 years ago

@kettanaito Sure. I will get to it in the coming days.

Do you think we should only expose selected existing interfaces, which should be the straightforward MVP solution?

It might be useful to add helpers for extracting and mapping types as well. I am not familiar with the existing generic shapes yet, so don't quote me on this 😅, but they might be harder to apply than some helpers that tailor to common use-cases. Right of the bat I am thinking of:

Also I am not sure whether or not the latter is really required. I was thinking of applying it to seed functions but they might work without it. Have to play around with it a bit more:

type UserModel = {} // get this somehow

// This example does not look really useful but gets the idea across.
// I already have seed functions that are more complex and useful. 
function seedUser(db: DB, overrides?: Partial<UserModel>) {
 return db.user.create(overwrites);
}

I will think about it a bit more and further helpers will crystallize from further usage.

kettanaito commented 2 years ago

I'd favor a more considered approach. Once we export these types they become our public API. It'd be harder to introduce changes without having our users constantly keep their codebases updated with the correct types (considering users that would use these types).

I feel that some of the internal types we use can be simplified (looking at you, Value) for the purpose of being used publicly. We may still keep some internal types as the generics structure of the library is extremely complex. We shouldn't expose that complexity to the end-user in any way.

I'd start by writing down a set of usage scenarios when explicit types would be needed. Then focused on exposing those types, refactoring or abstracting where applicable.

christoph-fricke commented 2 years ago

@kettanaito I agree. I have tried to use Entity and Value by importing them from the internal modules directly without much success. However, over the last weeks I have spend a lot of time writing tests with Playwright against an MSW + MSW Data mock backend and have gained insights into common use cases I encountered all the time. I noticed that all can be properly typed with just one helper type: Value (2.0).

While I did not manage to use Value from msw directly - because it does not work with the FactoryAPI returned by factory (?!?) - I have hacked together my own Value type that is derived from the FactoryAPI:

import { factory } from "@mswjs/data";
import type { ENTITY_TYPE, PRIMARY_KEY } from "@mswjs/data/lib/glossary";

export function createDB() {
  return factory({
    // ... all the models
  });
}
export type DB = ReturnType<typeof createDB>;

/** Helper to get the schema of an model. */
export type Value<Key extends keyof DB> = Omit<
  ReturnType<DB[Key]["create"]>,
  typeof ENTITY_TYPE | typeof PRIMARY_KEY
>;

Given that Value is just Entity without the two internal keys, this helper type can be used in every scenario that I encountered:

In conclusion, if a type is exposed, I think it should be sufficient to expose a type similar this helper type. Something like Value<DB, Key> = ... or Schema<DB, Key> = ....

micha149 commented 2 years ago

Currently I am writing a handler which returns a list of items which can be filtered. In my handler I am constructing a filters object which I merge into the where prop of findMany. For this I find the QuerySelectorWhere type useful.

import { QuerySelectorWhere } from '@mswjs/data/lib/query/queryTypes';
import type { ENTITY_TYPE, PRIMARY_KEY, FactoryAPI } from "@mswjs/data/lib/glossary";

// Just the types from @christoph-fricke, but I think we need DB as a generic paramter
type Value<DB extends FactoryAPI<Record<string, unknown>>, Key extends keyof DB> = Omit<
    ReturnType<DB[Key]['create']>,
    typeof ENTITY_TYPE | typeof PRIMARY_KEY
>;

// ...

// Here I want to specify that `filters` is an object with the structure required for my `where` condition
const filters: QuerySelectorWhere<Value<'membership'>> = {};

// And fill it later with values from the `URLSearchParams` of the request.
if (typeof queryParams.role !== 'undefined') {
    filters.role = { equals: queryParams.role };
}

if (typeof queryParams.active !== 'undefined') {
    filters.active = { equals: queryParams.active };
}

// And finally merge it into the query
const memberships = db.membership.findMany({
    where: {
        member: { id: { equals: member.id } },
        ...filters,
    },
    orderBy: [
        { user: { lastname: 'asc' } },
        { user: { firstname: 'asc' } },
    ],
});

// ...
emanuellarini commented 1 year ago

In my case here I need those types to create a Domain Transfer Object in msw responses, example using @christoph-fricke solution (renaming to Entity to clarify better)

export type Entity<Key extends keyof DB> = Omit<
  ReturnType<DB[Key]["create"]>,
  typeof ENTITY_TYPE | typeof PRIMARY_KEY
>;
export const account = {
  id: primaryKey(Number),
  name: String,
};
export const getAccountDTO = (accounts: Entity<'account'>[]) =>
  accounts.map(account => ({
    id: account.id,
    name: account.name
  }));
export const accountsHandler = [
  rest.get(`/v1/accounts`, (req, res, ctx) => {
    return res(
      ctx.json({
        items: getAccountDTO(db.account.getAll())
      })
    );
  })
];

I could also use the very same Entity type of account within my api responses and have just a single type for the Account entity across my whole app

The only problem with Chris solution is with relationships. It does not follow the nullable vs non-nullable setting as in some DTOs I had to do something like project.businessUnit?.id to avoid having type issues since business unit is a one-to-many non-nullable