gcanti / io-ts

Runtime type system for IO decoding/encoding
https://gcanti.github.io/io-ts/
MIT License
6.65k stars 331 forks source link

Generic Type of a Type #563

Open yankeeinlondon opened 3 years ago

yankeeinlondon commented 3 years ago

Ok, let me start by saying ... this isn't an issue with io-ts ... maybe more an issue with my numpty status as an io-ts user but I wasn't sure how else to get support (not that that's anyone's job really). Anyway, I have a feeling my issue is rather simple to the right person but will cause me a lot of heartache to solve on my own.

So my aim is to create a Table type which will represent a table in the database i'm using as a backend. I can type the table as an intersection (aka, optional and required props) without issue. But because beyond the is, encode/decode and validate functions I get from io-ts I also want to have strongly typed CRUD operations for all DB API calls. Long story short ... I want to decompose the surface area into a few parts and in order to do that I need the io-ts functions being fed the model as a "generic":

image

I looked at the docs and attempted what you see here. Sadly, I get error messages for each property about missing properties. As you can see from the screenshot, I had tried -- at least in the case of is() -- to bind to the root model as I thought maybe this was the source of the issue but it didn't move the needle at all.

In what I've labelled #2, I also have tried making reference to the model being passed in via this t.Type<any> inferenced approach as I've used this as a way to force TS to infer T for other aspects of the API to good effect before. This approach, however, didn't get my any further either.

Since I know some folks hate screenshots of code, here's the actual source:

import * as t from "io-ts";

export const TableIo = <T extends t.Type<any>>(model: T) =>
  t.type(
    {
      is: model.is.bind(model),
      encode: model.encode,
      decode: model.decode,
      validate: model.validate,
    },
    "TableIo"
  );

And I'd be passing in a model that would look like:

export const Member = t.intersection([
  t.type({
    ...MODEL,
    email: t.string,
    firstName: t.string,
    lastName: t.string,
  }),
  t.partial({
    maidenName: t.string,
    country: t.string,
    state: t.string,
    bio: t.string
  })
]);
yankeeinlondon commented 3 years ago

Also let me say, while I'm at it, if there's a better place to be doing this stuff please let me know. I appreciate all the lovely code you're providing to the community and definitely see the value in it. I do wonder if maybe there could be a list of "learning resources" listed on the site? I have done the obligatory google searching and watched what I could find on YouTube but i still find the learning curve really steep (I do not have a FP background so that does cause some of the interference).

gcanti commented 3 years ago

If I understand correctly in order to extend t.Type with your own operations you can use a class-based approach

import * as t from 'io-ts'

class Table<A, O, I> extends t.Type<A, O, I> {
  constructor(model: t.Type<A, O, I>) {
    super('TableIo', model.is, model.validate, model.encode)
  }
  // extend t.Type with your operations
  select<K extends keyof A>(_fields: ReadonlyArray<K>): Promise<ReadonlyArray<{ [_ in K]: A[_] }>> {
    return Promise.resolve([])
  }
}

export const TableIo = <A, O, I>(model: t.Type<A, O, I>): Table<A, O, I> => new Table(model)

const UserTable = TableIo(
  t.intersection([
    t.type({
      email: t.string,
      firstName: t.string,
      lastName: t.string
    }),
    t.partial({
      maidenName: t.string,
      country: t.string,
      state: t.string,
      bio: t.string
    })
  ])
)

/*
const result: Promise<readonly {
    firstName: string;
    lastName: string;
    country?: string | undefined;
}[]>
*/
const result = UserTable.select(['firstName', 'lastName', 'country'])

or a pipe-based approach

import { pipe } from 'fp-ts/function'
import * as t from 'io-ts'

const select = <A, K extends keyof A>(_fields: ReadonlyArray<K>) => <O, I>(
  _model: t.Type<A, O, I>
): Promise<ReadonlyArray<{ [_ in K]: A[_] }>> => Promise.resolve([])

const User = t.intersection([
  t.type({
    email: t.string,
    firstName: t.string,
    lastName: t.string
  }),
  t.partial({
    maidenName: t.string,
    country: t.string,
    state: t.string,
    bio: t.string
  })
])

/*
const result: Promise<readonly {
    firstName: string;
    lastName: string;
    country?: string | undefined;
}[]>
*/
const result = pipe(User, select(['firstName', 'lastName', 'country']))
yankeeinlondon commented 3 years ago

Yeah for now I'm using something similar to what you're suggesting in the class based approach. The table-based API I'd like to expose would not only expose CRUD operations such as select, insert, etc. but also proxy through io-ts's lower level features like validation/encoding. Here's the outline of what I'm doing:

export const Table = <T extends object>(
  model: t.Type<T>,
  client: SupabaseClient,
  options: ITableOptions = {}
): ITableDefinition<T> => {
  const name: Readonly<string> = options.name || snakerize(pluralize(model.name));
  if (!name) {
    throw new Error("No name was available from model (or passed in options)");
  }

  return {
    // basics
    name,
    kind: "Table",
    // io-ts
    is: model.is,
    encode: model.encode,
    decode: model.decode,
    // crud
    select: selectDefinition<T>(name, client),
    insert: insertDefinition<T>(name, client),
    update: updateDefinition<T>(name, client),
    delete: deleteDefinition<T>(name, client),
    // real-time
    subscribe: subscribeDefinition<T>(name, client),
    unsubscribe: unsubscribeDefinition<T>(name, client),
  };
};

Where the interface ITableDefinition is:

export interface ITableDefinition<T extends object> {
  kind: "Table";
  name: Readonly<string>;

  is: t.Mixed["is"];
  encode: t.Mixed["encode"];
  decode: t.Mixed["decode"];

  select: ISelect<T>;
  insert: IInsert<T>;
  update: IUpdate<T>;
  delete: IDelete<T>;

  subscribe: ISubscribe<T>;
  unsubscribe: IUnsubscribe<T>;
}

This actually does suit my current needs so there's no longer any urgency here but I had thought it might be nice to be more explicit about the structure of the Table API by defining ITableDefinition as an io-ts type and then converting the resulting structure into Typescript. I'm no longer sure this adds any real value in my current use case. That said, i'm still intellectually interested in how I'd provide a generic <T> which represents an io-ts Type into a another Type where the receiving type would then expose a subset of the Type API in it's own definition but remain type aware.

Not sure that last sentence was clear ... i'm still getting my feet in groking all the inference aspects of TS ... the result of which is that I figuratively speak with a slurred voice at times ;)