seasonedcc / composable-functions

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

Add pattern matching #42

Closed waspeer closed 1 year ago

waspeer commented 1 year ago

Hi!

I was just about to build something like this myself when I stumbled on this library haha. Great work! I love how you approached it.

A common pattern in Remix when having multiple forms on one page, is to add a hidden input named action to your form. It would be great to be able to combine domain functions with pattern matching to handle this. For example:

type Input =
  | {
      action: 'update';
      name: string;
    }
  | {
      action: 'delete';
      id: string;
    };

// Or possibly:
// type Input =
//   | UnpackInput<typeof updateProjectName>
//   | UnpackInput<typeof deleteStuff>;

export const action: ActionFunction = async ({ request }) => {
  const input = (await inputFromForm(request)) as Input;

  return match(input.action)
    .with({ action: 'update' }, (input) => updateProjectName(input))
    .with({ action: 'delete' }, (input) => deleteStuff(input))
    .otherwise(() => {
      throw new Error('Unexpected action');
    });
};

ts-pattern immediately comes to mind. Maybe this could be integrated or serve as inspiration.

Also, quick question; is this library actually specific to Remix? By the looks of it I can use this in any other framework, as long as it gets a Request as input?

gustavoguichard commented 1 year ago

Hey @waspeer ! I'm glad you like what you see! This is a great idea although it will require some investigation to implement it. We've been leaving this sort of branching inside the domain function itself for now as it could be considered business logic. But will definitely think about it.

We used to call this lib remix-domains but we realized it is not specific to remix at all thus we renamed it. FWIW it doesn't even require the Request/Response API as the bulk of the lib is the composition of DFs.

We are considering moving the Request/Response APIs to a complimentary lib btw.

Thank you for your comment!

danielweinmann commented 1 year ago

One additional comment: if you move the pattern matching to inside a domain function, when you throw new Error('Unexpected action') in your otherwise clause, it will return the error in the same format as the other cases. Then you can parse the result of only one domain function inside your action and decide what to do :)

waspeer commented 1 year ago

@gustavoguichard Sounds good! Let me know if I can help out at all.

@danielweinmann Good point! In my example I was imagining the match function to be part of domain-functions, so throwing an error/returning a value in the otherwise clause would also result in a Result object in the end.

gustavoguichard commented 1 year ago

Hey @waspeer make sure you check the proposed first combinator #43 . It is not exactly what you looked for but it solves a similar problem. With the combinator you'll be able to do this:

// domain/projects.server.ts
const updateProjectName = makeDomainFunction( z.object({ action: z.literal('update') }))(async () => {})
const deleteStuff = makeDomainFunction( z.object({ action: z.literal('delete') }))(async () => {})

// in your route
export const action: ActionFunction = async ({ request }) => {
  const result = await first(updateProjectName, deleteStuff)(await inputFromForm(request))
  if (!result.success) throw redirect('/error')

  return json(result.data)
};

The benefit here is that our proposed pattern for the loader/actions are kept and the composition won't break ;)

waspeer commented 1 year ago

That works for me :)

The benefit here is that our proposed pattern for the loader/actions are kept and the composition won't break

Out of curiousity, could you explain this a bit more? Just curious about your design process.

gustavoguichard commented 1 year ago

Sure @waspeer!

We like to keep our Remix Actions and Loaders all in the same shape:

export async function loader({ request }: LoaderArgs) {
  const result = await someDomainFunction(input, env)
  if (!result.success) throw notFound() // or something else

  return json(result.data)
}

And we try to solve all our problems with composition of domain functions so we have one domain function per loader/action.

This allows us to create Response helpers such as:

const loaderResponse = <T extends Result<X>, X>(
  result: T,
): T extends SuccessResult<X> ? TypedResponse<X> : TypedResponse<ErrorResult> =>
  result.success
    ? json(result.data, { status: 200 })
    : json(result, { status: 404 })

const loaderResponseOrThrow = <T extends Result<X>, X>(
  result: T,
): T extends SuccessResult<X> ? TypedResponse<X> : never => {
  if (!result.success) throw internalError(result.errors[0]?.message)

  return json(result.data, { status: 200 }) as any
}

const loaderResponseOrRedirect = <T extends Result<unknown>>(
  result: T,
  redirectPath: string,
): T extends { data: infer X } ? TypedResponse<X> : never => {
  if (!result.success) throw redirect(redirectPath)

  return json(result.data, { status: 200 }) as any
}

Which leads us to tiny and similar actions/loaders throughout the app:

// app/domain/my-domain.server.ts
const someDomainFunction = merge(dfA, dfB)

// app/routes/foo.tsx
export async function loader({ request }: LoaderArgs) {
  const result = await someDomainFunction(
    await someInputFunction(request),
    await someEnvFunction(request)
  )
  return loaderResponseOrRedirect(result, '/homepage')
}

By solving all our data needs with domain-function composition we can also properly type our components when needed:

// app/domain/foo.server.ts
const dfA = makeDomainFunction(z.object({}))(async () => ({ resultA: 'foo' }))
const dfB = makeDomainFunction(z.object({}))(async () => ({ resultB: 'foo' }))

const getFooData = merge(dfA, dfB)

// app/routes/foo.tsx
export async function loader({ request }: LoaderArgs) {
  return loaderResponseOrThrow(await getFooData({}))
}

export default () => {
  const data = useLoaderData<typeof loader>()
  return (
    <div>
      <MyFooComponent data={data} />
      <AComponent resultA={data.resultA} />
      <BComponent resultB={data.resultB} />
    </div>
}

// app/ui/my-foo-component.tsx
type Props = UnpackData<typeof getFooData>
function MyFooComponent({ resultA, resultB }: Props) {
  // ...
}

// app/ui/a-component.tsx
type Props = Pick<UnpackData<typeof getFooData>, 'resultA'>
function AComponent({ resultA }: Props) {
  // ...
}

// app/ui/b-component.tsx
type Props = Pick<UnpackData<typeof getFooData>, 'resultB'>
function BComponent({ resultB }: Props) {
  // ...
}

This is a little bit about how we've been using the domain-functions library at Seasoned. I also wrote a post about it although it is a bit dated as we've found more patterns and added more API surface to the library... it still worth a read to understand some of our reasoning though ;)

I hope it helps clarifying where we are heading to! Cheers

waspeer commented 1 year ago

Thanks for taking the time to elaborate! Makes a lot of sense :) As far as I'm concerned we can close this issue, unless you're still thinking about implementing pattern matching.

gustavoguichard commented 1 year ago

I'll close it for now but we are open to discuss it again in the future as pattern matching is 🎯🔥

gustavoguichard commented 3 months ago

Hey @waspeer , I was revisiting some issues and wanted to let you know our API has changed significantly and it is much more flexible and powerful now.

For conditionals you can also use the branch combinator