@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
22import { Effect , Schema , SchemaGetter } from "effect"
33import z from "zod"
44
5- import { zod , ZodOverride } from "../../src/util/effect-zod"
5+ import { zod , ZodOverride , ZodPreprocess } from "../../src/util/effect-zod"
66
77function json ( schema : z . ZodTypeAny ) {
88 const { $schema : _ , ...rest } = z . toJSONSchema ( schema )
@@ -751,4 +751,120 @@ describe("util.effect-zod", () => {
751751 expect ( schema . parse ( { foo : "hi" } ) ) . toEqual ( { foo : "hi" } )
752752 } )
753753 } )
754+
755+ describe ( "ZodPreprocess annotation" , ( ) => {
756+ test ( "preprocess runs on raw input before the inner schema parses" , ( ) => {
757+ // Models the permission.ts __originalKeys pattern: capture the original
758+ // insertion order of a user-provided object BEFORE Schema parsing
759+ // canonicalises the keys.
760+ const preprocess = ( val : unknown ) => {
761+ if ( typeof val === "object" && val !== null && ! Array . isArray ( val ) ) {
762+ return { __keys : Object . keys ( val ) , ...( val as Record < string , unknown > ) }
763+ }
764+ return val
765+ }
766+ const Inner = Schema . Struct ( {
767+ __keys : Schema . optional ( Schema . mutable ( Schema . Array ( Schema . String ) ) ) ,
768+ a : Schema . optional ( Schema . String ) ,
769+ b : Schema . optional ( Schema . String ) ,
770+ } ) . annotate ( { [ ZodPreprocess ] : preprocess } )
771+
772+ const schema = zod ( Inner )
773+ const parsed = schema . parse ( { b : "1" , a : "2" } ) as {
774+ __keys ?: string [ ]
775+ a ?: string
776+ b ?: string
777+ }
778+ expect ( parsed . __keys ) . toEqual ( [ "b" , "a" ] )
779+ expect ( parsed . a ) . toBe ( "2" )
780+ expect ( parsed . b ) . toBe ( "1" )
781+ } )
782+
783+ test ( "preprocess does not transform already-shaped input" , ( ) => {
784+ // When the user passes an object that already has __keys, preprocess
785+ // returns it unchanged because spreading preserves any existing key.
786+ const preprocess = ( val : unknown ) => {
787+ if ( typeof val === "object" && val !== null && ! ( "__keys" in val ) ) {
788+ return { __keys : Object . keys ( val ) , ...( val as Record < string , unknown > ) }
789+ }
790+ return val
791+ }
792+ const Inner = Schema . Struct ( {
793+ __keys : Schema . optional ( Schema . mutable ( Schema . Array ( Schema . String ) ) ) ,
794+ a : Schema . optional ( Schema . String ) ,
795+ } ) . annotate ( { [ ZodPreprocess ] : preprocess } )
796+
797+ const schema = zod ( Inner )
798+ const parsed = schema . parse ( { __keys : [ "existing" ] , a : "hi" } ) as {
799+ __keys ?: string [ ]
800+ a ?: string
801+ }
802+ expect ( parsed . __keys ) . toEqual ( [ "existing" ] )
803+ } )
804+
805+ test ( "preprocess composes with a union (either object or string)" , ( ) => {
806+ // Mirrors permission.ts exactly: input can be either an object (with
807+ // preprocess injecting metadata) or a plain string action.
808+ const Action = Schema . Literals ( [ "ask" , "allow" , "deny" ] )
809+ const Obj = Schema . Struct ( {
810+ __keys : Schema . optional ( Schema . mutable ( Schema . Array ( Schema . String ) ) ) ,
811+ read : Schema . optional ( Action ) ,
812+ write : Schema . optional ( Action ) ,
813+ } )
814+ const preprocess = ( val : unknown ) => {
815+ if ( typeof val === "object" && val !== null && ! Array . isArray ( val ) ) {
816+ return { __keys : Object . keys ( val ) , ...( val as Record < string , unknown > ) }
817+ }
818+ return val
819+ }
820+ const Inner = Schema . Union ( [ Obj , Action ] ) . annotate ( { [ ZodPreprocess ] : preprocess } )
821+ const schema = zod ( Inner )
822+
823+ // String branch — passes through preprocess unchanged
824+ expect ( schema . parse ( "allow" ) ) . toBe ( "allow" )
825+
826+ // Object branch — __keys injected, preserves order
827+ const parsed = schema . parse ( { write : "allow" , read : "deny" } ) as {
828+ __keys ?: string [ ]
829+ read ?: string
830+ write ?: string
831+ }
832+ expect ( parsed . __keys ) . toEqual ( [ "write" , "read" ] )
833+ expect ( parsed . write ) . toBe ( "allow" )
834+ expect ( parsed . read ) . toBe ( "deny" )
835+ } )
836+
837+ test ( "JSON Schema output comes from the inner schema — preprocess is runtime-only" , ( ) => {
838+ const Inner = Schema . Struct ( {
839+ a : Schema . optional ( Schema . String ) ,
840+ b : Schema . optional ( Schema . Number ) ,
841+ } ) . annotate ( { [ ZodPreprocess ] : ( v : unknown ) => v } )
842+ const shape = json ( zod ( Inner ) ) as any
843+ expect ( shape . type ) . toBe ( "object" )
844+ expect ( shape . properties . a . type ) . toBe ( "string" )
845+ expect ( shape . properties . b . type ) . toBe ( "number" )
846+ } )
847+
848+ test ( "identifier + description propagate through the preprocess wrapper" , ( ) => {
849+ const Inner = Schema . Struct ( {
850+ x : Schema . optional ( Schema . String ) ,
851+ } )
852+ . annotate ( {
853+ identifier : "WithPreproc" ,
854+ description : "A schema with preprocess" ,
855+ [ ZodPreprocess ] : ( v : unknown ) => v ,
856+ } )
857+ const schema = zod ( Inner )
858+ expect ( schema . meta ( ) ?. ref ) . toBe ( "WithPreproc" )
859+ expect ( schema . meta ( ) ?. description ) . toBe ( "A schema with preprocess" )
860+ } )
861+
862+ test ( "preprocess inside a struct field applies only to that field" , ( ) => {
863+ const Inner = Schema . String . annotate ( {
864+ [ ZodPreprocess ] : ( v : unknown ) => ( typeof v === "number" ? String ( v ) : v ) ,
865+ } )
866+ const schema = zod ( Schema . Struct ( { name : Inner , raw : Schema . Number } ) )
867+ expect ( schema . parse ( { name : 42 , raw : 7 } ) ) . toEqual ( { name : "42" , raw : 7 } )
868+ } )
869+ } )
754870} )
0 commit comments