Skip to content

Support cyclical type inference of self-referential data structures, when subject to some assignability constraints #61659

Open
@colinhacks

Description

@colinhacks

🔍 Search Terms

cyclical, recursive, inference, self-referential, extends, schema, zod


I'm trying to get recursive schemas working in Zod, powered by TypeScript's ability to handle self-reference inside getters. Example:

const Category = z.object({
  name: z.string(),
  get parent(){
    // ^ 'parent' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.ts(7023)
    return Category.optional()
  }
})

This would be a massive usability win for Zod and other schema/ORM libraries. None of these are able to directly represent cyclical types currently without redundant type annotations or some "scope"/"registry" concept to defer type resolution and later perform some kind of recursive substitution.

My understanding is that TypeScript is generally capable of inferring a recursive type with it's ... approach as long as there aren't any assignability constraints on the data being inferred.

const a = {
  get self() {
    return a;
  },
}

Applying any constraint breaks this capability. But in the case below, it seems potentially possible to typecheck the cyclical type against the constraint without issue.

const a = {
  get self() {
  //  ^ does not have a return type annotation and is referenced directly or indirectly in one of its return expressions
    return a;
  },
} satisfies { self: any };

I think if the compiler was able to properly infer a above despite the satisfies clause, Zod could then support recursive objects in the way I'd described.

✅ Viability Checklist

⭐ Suggestion

Support inference on self-referential/cyclical data structures in more cases

📃 Motivating Example

Below is a more motivated example that I think demonstrates the same fundamental limitation I referenced earlier.

// base type
interface ZodType {
  optional: "true" | "false";
  output: any;
}

// string
interface ZodString extends ZodType {
  optional: "false";
  output: string;
}

// object
type ZodShape = Record<string, any>;
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type InferObjectType<Shape extends ZodShape> = Prettify<
  {
    [k in keyof Shape as Shape[k] extends { optional: "true" } ? k : never]?: Shape[k]["output"];
  } & {
    [k in keyof Shape as Shape[k] extends { optional: "true" } ? never : k]: Shape[k]["output"];
  }
>;
interface ZodObject<T extends ZodShape> extends ZodType {
  optional: "false";
  output: InferObjectType<T>;
}

// optional
interface ZodOptional<T extends ZodType> extends ZodType {
  optional: "true";
  output: T["output"] | undefined;
}

// factories
declare function object<T extends ZodShape>(shape: T): ZodObject<T>;
declare function string(): ZodString;
declare function optional<T extends ZodType>(schema: T): ZodOptional<T>;

// recursive type inference error
const Category = object({
  name: string(),
  get parent() {
      // ^ 'parent' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.ts(7023)
    return optional(Category);
  },
});

💻 Use Cases

  1. What do you want to use this for?

To support more ergonomic representations of inherently cyclical data structures (Zod schemas, database schemas, state diagrams, etc)

  1. What shortcomings exist with current approaches?

Not possible

  1. What workarounds are you using in the meantime?

None/not possible

Metadata

Metadata

Assignees

No one assigned

    Labels

    Help WantedYou can do thisPossible ImprovementThe current behavior isn't wrong, but it's possible to see that it might be better in some cases

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions