Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pattern matching #42

Closed
waspeer opened this issue Oct 13, 2022 · 8 comments
Closed

Add pattern matching #42

waspeer opened this issue Oct 13, 2022 · 8 comments

Comments

@waspeer
Copy link

waspeer commented Oct 13, 2022

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
Copy link
Collaborator

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
Copy link
Contributor

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
Copy link
Author

waspeer commented Oct 13, 2022

@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
Copy link
Collaborator

gustavoguichard commented Oct 18, 2022

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
Copy link
Author

waspeer commented Oct 19, 2022

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
Copy link
Collaborator

gustavoguichard commented Oct 19, 2022

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
Copy link
Author

waspeer commented Oct 19, 2022

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
Copy link
Collaborator

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants