effect icon indicating copy to clipboard operation
effect copied to clipboard

RFC: Retaining Struct Schema with Transformations in `Schema.typeSchema`

Open effect-bot opened this issue 1 year ago • 3 comments

Summary

Summary

The user is trying to retain the schema structure of a StructSchema while applying transformations using Schema.typeSchema. They provided a TypeScript function to achieve this but were concerned about its safety and compatibility with PropertySignature.

Key Takeaways

  1. Initial Attempt:

    • The user provided a function structTypeSchema to transform a StructSchema while retaining its fields.
    • The function was deemed unsafe because it didn't account for PropertySignature.
  2. Safety Improvement:

    • A suggestion was made to narrow the constraint to ensure safety:
    declare const structTypeSchema: <Fields extends { readonly [x: PropertyKey]: Schema.Schema.All }>(
      schema: Schema.Struct<Fields>
    ) => Schema.Struct<{ [K in keyof Fields]: Schema.Schema<Schema.Schema.Type<Fields[K]>> }>
    
  3. Compatibility with PropertySignature:

    • The user expressed a desire to use the function with PropertySignatures.
    • A more refined implementation was provided to handle different AST tags:
    import { AST, Schema } from "@effect/schema"
    import { Record } from "effect"
    
    export const structTypeSchema = <Fields extends Schema.Struct.Fields>(
      schema: Schema.Struct<Fields>
    ): Schema.Struct<{ [K in keyof Fields]: Schema.Schema<Schema.Schema.Type<Fields[K]>> }> =>
      Schema.Struct(Record.map(schema.fields, (field) => {
        switch (field.ast._tag) {
          case "PropertySignatureDeclaration":
            return Schema.make(AST.typeAST(field.ast.type))
          case "PropertySignatureTransformation":
            return Schema.make(AST.typeAST(field.ast.to.type))
          default:
            return Schema.make(AST.typeAST(field.ast))
        }
      })) as any
    
  4. Convenience vs. Tree-Shakeability:

    • A suggestion was made to add a static method to Struct for convenience:
    Schema.Struct({ a: Schema.NumberFromString }).typeSchema()
    
    • However, this approach might not be tree-shakeable and may not justify the addition due to its specificity.
  5. Community Feedback:

    • It was suggested to open an issue to gather feedback and assess interest in adding this feature.

Conclusion

The refined function provided meets the user's requirements, and there is a potential for further enhancement based on community feedback.

Discord thread

https://discord.com/channels/795981131316985866/1266533881788502096

effect-bot avatar Jul 29 '24 13:07 effect-bot

could this possibly be cursive as well for nested structs?

ethanniser avatar Jul 29 '24 18:07 ethanniser

A limitation of the implementation in the summary is that the resulting type does not preserve the optionality of keys. This can make composing a little confusing:

const fields = {
  optional: S.String.pipe(S.optional),
  required: S.String,
}
// const schema: S.Schema<{
//     readonly optional?: string | undefined;
//     readonly required: string;
// }, {
//     readonly required: string;
//     readonly optional?: string | undefined;
// }>
const schema = S.Struct(fields)


// type typeSchema = S.Struct<{
//     optional: S.Schema<string | undefined, string | undefined, never>;
//     required: S.Schema<string, string, never>;
// }>
// --->
// typeSchema = S.Schema<{
//     readonly optional: string | undefined;
//     readonly required: string;
// }, {
//     readonly optional: string | undefined;
//     readonly required: string;
// }, never>
const typeSchema = structTypeSchema(schema)

So I instead redefined structTypeSchema using a modified S.Struct.Type

type TypeFields<F extends S.Struct.Fields> =
  Types.UnionToIntersection<
    Exclude<{
      [K in keyof F]: F[K] extends OptionalPropertySignature
      ? { readonly [H in K]?: S.Schema<S.Schema.Type<F[H]>> }
      : { readonly [h in K]: S.Schema<S.Schema.Type<F[h]>> }
    }[keyof F], undefined>
    // using this instead of `infer Q ? Q : never` because otherwise the resulting
    // fields are not usable when passed to functions of the type:
    // <Fields extends S.Struct.Fields>(schema: S.Struct<Fields>) => any
    // because `never` is not assignable to `S.Struct.Fields`
> extends infer Q ? (Q extends S.Struct.Fields ? Q : {}) : {}

type TypeStruct2<F extends S.Struct.Fields> = S.Struct<TypeFields<F>>
type structTypeSchema2 = <Fields extends S.Struct.Fields>(schema: S.Struct<Fields>) => TypeStruct2<Fields>
const structTypeSchema2 = structTypeSchema as unknown as structTypeSchema2

const typeSchema2 = structTypeSchema2(schema)
const typeSchema2: TypeStruct2<{
    optional: S.optional<typeof S.String>;
    required: typeof S.String;
}>

Yay!

But no!

const asSchema = S.asSchema(structTypeSchema2(schema))
const typeSchema2: S.Schema<never, {
    readonly optional: string | undefined;
    readonly required: string;
}, never>
// --->
// type schemaType = never ??? 
type schemaType = S.Struct.Type<typeof typeSchema2['fields']>

This is because in of S.Struct.Type:

{
  [K in keyof F]: F[K] extends OptionalPropertySignature
    ? { readonly [H in K]?: Schema.Type<F[H]> }
    : { readonly [h in K]: Schema.Type<F[h]> }
}[keyof F]

resolves to:

{
   readonly optional?: string | undefined;
} | {
  readonly required: string;
} | undefined

Which resolves to never when wrapped in Types.UnionToIntersection<>

So in order for this to interoperate smoothly with the rest of schema, I think that S.Struct.Type needs to be updated to include Types.UnionToIntersection<Exclude<..., undefined>>

nspaeth avatar Sep 08 '24 20:09 nspaeth

Actually, my mistake. That would create an invalid schema - I confused the schema optionality with the type optionality. I think that just using this would work:

export type OptionalPropertySignatureWithoutDefault =
  | S.PropertySignature<"?:", any, PropertyKey, S.PropertySignature.Token, any, false, unknown>
  | S.PropertySignature<"?:", any, PropertyKey, S.PropertySignature.Token, never, false, unknown>
  | S.PropertySignature<"?:", never, PropertyKey, S.PropertySignature.Token, any, false, unknown>
  | S.PropertySignature<"?:", never, PropertyKey, S.PropertySignature.Token, never, false, unknown>

export type TypeFields<F extends S.Struct.Fields> =
  Types.UnionToIntersection<
    Exclude<{
      [K in keyof F]: {
        readonly [H in K]: F[K] extends OptionalPropertySignatureWithoutDefault
           ? S.optional<S.Schema<S.Schema.Type<F[H]>>>
           : S.Schema<S.Schema.Type<F[H]>>
      }
    }[keyof F], undefined>
  > extends infer Q ? (Q extends S.Struct.Fields ? Q : {}) : {}

nspaeth avatar Sep 08 '24 22:09 nspaeth