zod icon indicating copy to clipboard operation
zod copied to clipboard

Add plugin for Effect

Open colinhacks opened this issue 1 year ago • 12 comments

Addmittedly, this is a bit of trial run for a Zod plugin system I plan to announce more formally in Zod 4. But also Effect is such a perfect use case for a Zod plugin that I couldn't resist!

Without a plugin, a Zod + Effect integration would require some kind of adapter/resolver system, and imo those always feel a little hacky. With a plugin, we can add strongly-typed Effect-specific methods to the ZodType base class with one line of code.

This has already been published to @zod-plugin/[email protected] if people want to try it. Sample usage:

import * as z from "zod";
import { Effect } from "effect";

// this adds the `effect/effectSync` methods to Zod's base class
import "@zod-plugin/effect"; // sideEffects: true

// sync
const syncSchema = z.object({ name: z.string() });
const syncEffect = syncSchema.effectSync({ name: "Giulio Canti" });
console.log(Effect.runSync(syncEffect));; // => { name: "Giulio Canti" }

// async
const asyncSchema = syncSchema.refine(async ()=>true);
const asyncEffect = asyncSchema.effect({ name: "Mike Arnaldi" });
console.log(await Effect.runPromise(asyncEffect));; // => { name: "Michael Arnauldi" }

I'm far from an Effect expert, so I'd appreciate any feedback from @gcanti or @mikearnaldi. Zod was mentioned in passing in the 3.0 launch post - I'm curious what kind of API you had in mind and how this compares. This is intentionally super minimal. I admit I haven't fully wrapped my head around the API surface of @effect/schema.

API alternatives

Would it be more conventional to rename .effect() to .effectPromise() so there's tighter agreement with Effect's .run*() methods? I don't love this personally but agreement with convention is more important. Effect isn't consistently explicit with the sync/promise dichotomy in its APIs (e.g. it's .try instead of .trySync) so I thought I might be able to get away with just .effect. 😅

schema.effectPromise();
schema.effectSync();

I also briefly considered a .effect() method that returns some kind of ZodEffect instance. This would make it possible to configure any Effect specific stuff in a params object. The result could contain Effect-ified versions of Zod's usual methods: .parse, .parseAsync, .safeParse, .safeParseAsync:

z.string().effect().parse();
z.string().effect().safeParseAsync();

Or if we think no configuration is necessary:

z.string().effect.parse();
z.string().effect.safeParseAsync();

I also wasn't clear if the parse input should be considered a Requirement. There wasn't much on the Creating Effects page about Requirements.

Async effects w/ errors

Small thing: I found myself looking for an easier way to instantiate asynchronous Effect<A, E> more easily. Something like suspend but that can return a promise (though I assume there's a good reason why this isn't supported). I'm using the .try/.tryPromise methods but I believe it relies on try/catch internally? That'll be a problem for performance-sensitive stuff like Zod.

Here's what I want to be able to do:

function zodEffectAsync(this: z.ZodType, data: unknown) {
  return Effect.promise(async () => {
    const result = await this.safeParseAsync(data);
    if (result.success === true) {
      return Effect.succeed(result.data);
    }
    return Effect.fail(result.error);
  });
}

Side-effectful plugins

I designed this plugin to work as a simple side-effect import.

import "@zod-plugin/effect";

I'm not sure how will this will work in conjunction with modern frameworks that lack an obvious single "entrypoint", but I think there's probably a place you could put this in, say, a Remix/Next.js application where it'll always get properly bundled/executed before other code. If anyone has experience with other plugin systems like this, chime in. The alternative is to do a dependency injection thing:

import * as z from "zod";
import EffectPlugin from "@zod-plugin/effect";

EffectPlugin.initialize(z);

But since Zod is a peer dependency of @zod-plugin/effect, this isn't actually necessary afaict.

colinhacks avatar Apr 26 '24 00:04 colinhacks

Deploy Preview for guileless-rolypoly-866f8a ready!

Name Link
Latest commit 402288a93dd6f29a1b2ecff287345413ffd07e01
Latest deploy log https://app.netlify.com/sites/guileless-rolypoly-866f8a/deploys/662d6d92e3a45100088beeb8
Deploy Preview https://deploy-preview-3445--guileless-rolypoly-866f8a.netlify.app
Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

netlify[bot] avatar Apr 26 '24 00:04 netlify[bot]

This is cool! Made some suggestions here: https://github.com/colinhacks/zod/pull/3449

tim-smart avatar Apr 27 '24 09:04 tim-smart

Some design suggestions:

Effect doesn't make a difference between sync and async, it feels more appropriate to just expose something like schema.parseEffect({ ... }) that returns an Effect which is either sync or async depending on if the schema has or not async transforms

Regarding how to make an effect which can be either sync or async you could use

Effect.async((resume) => {
  doStuff(() => {
    resume(Effect.succeed/fail)
  })
})

while the name says async it would be more appropriate to call it callback given that it doesn't mind if the callback is called in sync or async

mikearnaldi avatar Apr 27 '24 17:04 mikearnaldi

Effect which is either sync or async depending on if the schema has or not async transforms

Unfortunately Zod doesn't actually know ahead of time if a schema contains async transforms are not. If it encounters a Promise during .parse() (a synchronous operation) then it throws an error. So I think the best Zod could do here is attempt a sync parse and fallback to async if an error is thrown which feels icky.

colinhacks avatar Apr 27 '24 21:04 colinhacks

Effect which is either sync or async depending on if the schema has or not async transforms

Unfortunately Zod doesn't actually know ahead of time if a schema contains async transforms are not. If it encounters a Promise during .parse() (a synchronous operation) then it throws an error. So I think the best Zod could do here is attempt a sync parse and fallback to async if an error is thrown which feels icky.

Effect has a similar issue but when you use runSync and an async op is found the Error thrown contains a continuation that the caller can then await asynchronously, wondering if it can be possible here too so that instead of re-doing we continue

mikearnaldi avatar Apr 27 '24 21:04 mikearnaldi

It certainly should be possible for Zod's parsing engine to return a Promise only when necessary. In Zod 4 the parsing engine is getting refactored, so I'll try to include this as a design constraint. I agree that a single .parseEffect() would be fantastic.

Effect has a similar issue but when you use runSync and an async op is found the Error thrown contains a continuation that the caller can then await asynchronously, wondering if it can be possible here too so that instead of re-doing we continue

Hm interesting, I'll ponder that. The dumb version of that is for the continuation to just re-run the whole parse with parseAsync. Which would be pretty easy to do.

But from a user perspective I think a default-async method + an opt-in "enforce sync" is a pretty good DX. The only reason for an Effect user to enforce synchronous is performance.

colinhacks avatar Apr 27 '24 21:04 colinhacks

It certainly should be possible for Zod's parsing engine to return a Promise only when necessary. In Zod 4 the parsing engine is getting refactored, so I'll try to include this as a design constraint. I agree that a single .parseEffect() would be fantastic.

Effect has a similar issue but when you use runSync and an async op is found the Error thrown contains a continuation that the caller can then await asynchronously, wondering if it can be possible here too so that instead of re-doing we continue

Hm interesting, I'll ponder that. The dumb version of that is for the continuation to just re-run the whole parse with parseAsync. Which would be pretty easy to do.

But from a user perspective I think a default-async method + an opt-in "enforce sync" is a pretty good DX. The only reason for an Effect user to enforce synchronous is performance.

In reality given that Effect doesn't use promises nor forcing next ticks the only reason to force sync is when you absolutely have to, e.g. you're doing something in a react component that should occur before render or similar edge cases. Looking forward to Zod 4

mikearnaldi avatar Apr 27 '24 21:04 mikearnaldi

@mikearnaldi I'm a little fuzzy on your recommendation here. Do you think this should use the continuation approach is better, or are you happy with the two-method API? If you're ok with two methods...any nitpicks around naming? Deferring to you on this.

colinhacks avatar Apr 29 '24 21:04 colinhacks

I don't have a string preference but if the continuation approach is inefficient then two methods would be fine. Naming isn't my best quality :)

mikearnaldi avatar Apr 29 '24 21:04 mikearnaldi

I think for the names, you could match the conventions you have already established.

// non-effect
schema.parse(...)
schema.parseAsync(...)

// effect
schema.parseEffect(...)
schema.parseAsyncEffect(...)

Bit longer, but keeps all the parsing apis in the same place.

tim-smart avatar Apr 29 '24 22:04 tim-smart

@mikearnaldi I'm a little fuzzy on your recommendation here. Do you think this should use the continuation approach is better, or are you happy with the two-method API? If you're ok with two methods...any nitpicks around naming? Deferring to you on this.

I had a quick look, and it seems zod will always return a Promise in "async mode"? So two seperate apis makes sense.

tim-smart avatar Apr 29 '24 22:04 tim-smart

I had a quick look, and it seems zod will always return a Promise in "async mode"? So two seperate apis makes sense.

That's correct, though liable to change in Zod 4. But this is intended to work with Zod 3, so I'll go with two APIs 👍

colinhacks avatar Apr 30 '24 01:04 colinhacks

Dear @colinhacks ,

https://github.com/colinhacks/zod/blob/402288a93dd6f29a1b2ecff287345413ffd07e01/plugin/effect/src/index.ts#L24-L26

could you please explain: is altering the prototypes of ZodType being a recommended way to extend Zod functionality? Is it safe? Or will there be a better way to add custom methods, like .example(), in the future Zod 4 ?

RobinTail avatar May 01 '24 20:05 RobinTail

@RobinTail Yes, this is how plugins are going to work. The Vue (v2) and Dayjs plugin systems are the prior art here. Zod has always been hackable - that's why it exports so many helper types and utilities. Extending prototypes is a part of JavaScript like any other, and it's I think it's a very underutilized pattern. I'm not aware of any other way to achieve similar functionality.

Zod could provide some helper functions to make this look cleaner (e.g. addMethods(ZodType, { ...methods })) but the prototype will still get modified under the hood.

colinhacks avatar May 02 '24 20:05 colinhacks