seasonedcc / composable-functions

Types and functions to make composition easy and safe
MIT License
649 stars 13 forks source link

Proposal: Should be able to pull out the expected input type from a build domain function #126

Closed jjhiggz closed 8 months ago

jjhiggz commented 8 months ago

Aloha!

I was going to try and use domain functions to build out a type safe fetcher example. One of the things that I think could be really useful for this would be if I could pull out the "Expected Input" type from a built domain function, so that way we would know what to plug into the fetcher and get across the wire typesafety.

Basically the goal would be that you could do something like this

const addCharacter = makeDomainFunction(z.object({name: z.string()}))(
  async (newCharacter) => {
    return await characters.push({
      ...newCharacter,
      id: randomInteger(1, 1000000),
    });
  }
);

type InferredInput = InferDomainFnInput<typeof addCharacter> // { name: string }

I actually was able to accomplish this pretty easy with just a couple of lines of code by modifying these definitions.

declare type DomainFunction<Output = unknown, InputSchemaType = undefined> = {
    (input: unknown, environment?: unknown, expectedInput?: InputSchemaType): Promise<Result<Output>>;
     // added "expectedInput" which will never actually be used as an argument but can be positionally used to track the expected 
};

And also modified the MakeDomainFunction type

declare type MakeDomainFunction = <Schema extends z.ZodTypeAny, EnvSchema extends z.ZodTypeAny>(inputSchema: Schema, environmentSchema?: EnvSchema) => <Output>(handler: (inputSchema: z.infer<Schema>, environmentSchema: z.infer<EnvSchema>) => Promise<Output>) => DomainFunction<Output, z.infer<Schema>>;

// Plugged in the expected input as the second generic for `DomainFunction`

Now you can pull out the expected input by doing

type InferredDomainFnInput<T> = Exclude<Paramaters<typeof addCharacter>[2], undefined>

^^^ This is the end of the mechanics on how to actually extract the input value, which I imagine would have some valid use cases outside of what I'm about to describe. But keep reading if you want to see the grander vision

Now in theory you could build out fetchers in remix by wrapping useFetcher with a custom hook that you can plug the typeof a domain-fn into. So basically you could structure nearly all of your actions like this.

import { myDomainFn }  from "./domain-fns"

const action  = ({req}) => {
      return myDomainFn(await req.body)
}

and you could generate a routes.types.ts that looks something like this

import { domainFn1 } from "./domain-fns/df1.ts"
import { domainFn2 } from "./domain-fns/df2.ts"

export type Routes = {
   "path-to-route-1": InferDomainFnInput<typeof domainFn1>,
   "path-to-route-2": InferDomainFnInput<typeof domainFn2>
}

Or something like that ^ then build a useFetcher hook that allows you to select a path from the key of those routes

In theory it would be better typesafety if you could plug into the action itsself which domain-fns are being used. But I haven't figured out how to do that.

diogob commented 8 months ago

Hi @jjhiggz thanks for taking the time for this proposal. The expected input type of a DF get very tricky very fast, that's why so far we haven't exposed it. You can see a bit of this history here.

The composition is where things start to get dificult since you have to calculate new types based on multiple DFs. Another problem with this sort of approach is that would only capture the expected type inside the DF. You would have no knowlege of how the parser works. Just to illustrate, imagine a DF:

const cannotBeCalled = mdf(
  z.preprocess(() => {
    throw new Error('You cannot know what the parser does from its type')
  }, z.number()),
)((input) => input)

It might strike you as a silly example, but the principle applies to several cases. It's just in the way domain functions are designed (to be insulated from an unstructured world using parsers).

However, if you take the parsers out, one could do something like that, and compose the parsers just before plugging the function in your action. I believe this will be soon possible using a new type called Composable. You can see some of that work in progress.

I'm closing this issue but feel free to open a discussion in case you have another proposal in mind or just want to further discuss this topic.

jjhiggz commented 8 months ago

@diogob Thank you for explaining that! Seems like it will be extremely useful when you get it figured out.

gustavoguichard commented 3 months ago

Hey @jjhiggz the new version of this library - which is called composable-functions and was released today - should work as you expect! Now the arguments of a functions are in the type:

const add = composable((a: number, b: number) => a + b)
//         ^? Composable<(a: number, b: number) => number>

Compositions are also type-checked and you shouldn't be able to compose functions that have unmatching arguments.

Take a look on the README and let us know how it goes!