Skip to content

Inconsistent compiler behavior with recursive conditional type as variables or parameters #60221

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

Closed
fwolff opened this issue Oct 14, 2024 · 3 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@fwolff
Copy link

fwolff commented Oct 14, 2024

πŸ”Ž Search Terms

recursive conditional type compilation variable parameter

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about recursive conditional types

⏯ Playground Link

https://www.typescriptlang.org/play/?#code/FAFwngDgpgBAyiATgSwHYHMDCB7VBnJAQzRDxgF4YBvGRKARwFdk6ATAfgC4YAjbbADZRCqGAF9QkWAHkeAKygBjEDnxESZSjTpMWUDtz6DhoiZOjwkadHEUALKAFtCFGAG0ARARQYPAGksfLFxvYlRSAF1zGXklEFsHZwAeABUYKAAPEChUVjJsWOUYAB8YVEYBARKYRlyoADM0fQA+V08ChWV-ancABRg0GABrKDBsepgUiO4Ep0IAMWxEVLdeiNaxKOj4eznF5ZTWymAYU-cp9KycvPdva2ryyura1gam1giYdkDrWecYTgnM5uC6ZbK5MhuDpxB4VKqlF5vVD6T7fWSdeK7ZKHAFA07IgBuUEQwG2AEFUGA-i5KAggtTqui4tSklQxM1SeALL1iXhcK4qHiyoRHFBuHcMLDKsAzPVaspkPyCYQBMhWIRsgAKPBYwjcClU3UASmoQroIEYiFEbiiZkUIRAMB1iUIAEY2h5oV0AoKzsLRdxPBL0N1tAxmGxuEhGLBNn4ZVt7WonbqAEz6ykMyjtQogUNC1AisXuLxWXw+2jhvSsKOIGPiCLxzakpMEFMugDMM11+ySPMQfNQRxLXrzPoLRcDpaCocrukjMGjscbCdJytV6q1zrmrqNwAA9PuYABRRCIJb6xDoRii8IwcaLqQwADk2rL6GqNELAZgb6Cn7nCN9EMfghBEABucQjRtSCxGgiJnwGMhUGwR1CDwPBkHQQseCERdsBgCBCEQItskQe8Ji5WBnwNalnwAOgPI9JifV9gwA79iz-e5SjDedgN4UCTFg+CRJtRDkGQ1CYHQzDsMIXDYBAAiqJfJllBZNlmgYpi-RSYj0CgR0dCAshU3SIRbxAbUTR4RhHT5S1FFgZwwBgOxCCJGB6igAB3Yl6M1VMOwAFgAVj3YB1zVDUoG1NMTUPGAADkVKfXZFCGawAk4mTckA6sYEUEReBcyToEqfQopVGKt11DtEqPAB1JYhjIdDLmgZR9ACVSMqyyVJPvIZqo3WLNRzDFZySlrEDawjiJAZAVQEMBevShxMvuXzWvyURPVzDxeHssppP5adrCOpYYA8Ezqw8CcfyDd9ZzuhclwbJsIkioA

πŸ’» Code

type StringConstraints = { required?: boolean }
type ObjectConstraints = { required?: boolean }

type StringSchema = ["string", StringConstraints]
type ObjectSchema<T extends object | null | undefined> = ["object", { [P in keyof T]: SchemaFor<T[P]> }]

type SchemaFor<T> = 
    [T] extends [string | null | undefined] ? StringSchema :
    [T] extends [object | null | undefined] ? ObjectSchema<T> :
    never

type AnySchema = StringSchema | ObjectSchema<{}>

type Person = {
    name: string | null
}

function validate(schema: AnySchema) {
    return []
}

const schema1 = ["object", {
    name: ["string", { required: true }],
}]

const schema2: AnySchema = ["object", {
    name: ["string", { required: true }],
}]

const schema3: SchemaFor<Person> = ["object", {
    name: ["string", { required: true }],
}]

validate(schema1)
// Error: Argument of type '(string | { name: (string | { required: boolean; })[]; })[]' is not assignable to parameter of type 'AnySchema'.
//  Type '(string | { name: (string | { required: boolean; })[]; })[]' is not assignable to type 'ObjectSchema<{}>'.
//    Target requires 2 element(s) but source may have fewer.(2345)

validate(schema2) // No type checking, name and required can be mispelled
validate(schema3) // Works as expected, type checking is ok
validate(["object", { // Works partially, type checking works on "object" but not on "string" or "required"
    name: ["string", { required: true }],
}])

πŸ™ Actual behavior

Type-checking only works with schema3, where all properties have to be correctly spelled. schema1 gives a compiler error (see code). schema2 can be completely misspelled with no errors. Passing directly a schema as parameter to the validate function works partially.

πŸ™‚ Expected behavior

All four schema definitions should work with correct and complete type-checking.

Additional information about the issue

No response

@jcalz
Copy link
Contributor

jcalz commented Oct 14, 2024

I don't see a TypeScript bug anywhere in here. Your type ObjectSchema<{}> is just ["object", {}], and that's quite a wide type that accepts just about anything in the second element. You're not really using generics in a way that does much, for most of this code. TypeScript has no idea in schema1 that you care about the literal types of the string literals in there. I don't see where in this code you have given TypeScript the information it would need to check things the way you want.

If I were to try to write code like this, I'd make validate() generic, make sure that const assertions didn't invalidate your types (so you should accept readonly tuples), and either write your object literals directly as arguments to validate(), or use const assertions ahead of time, and never annotate to a type wider than you care about. Maybe something like

type StringConstraints = { required?: boolean }
type StringSchema = readonly ["string", StringConstraints]

type ObjectSchema<T extends object | null | undefined> =
    readonly ["object", { [P in keyof T]: SchemaFor<T[P]> }]


type SchemaFor<T> =
    [T] extends [string | null | undefined] ? StringSchema :
    [T] extends [object | null | undefined] ? ObjectSchema<T> :
    StringSchema

type AnySchema = StringSchema | ObjectSchema<{}>

type Person = {
    name: string | null
}

const schema1 = ["object", {
    name: ["string", { required: true }],
}] as const


const schema3: SchemaFor<Person> = ["object", {
    name: ["string", { required: true }],
}]

type ValidateSchema<T> = T extends StringSchema ? T :
    T extends readonly ["object", infer S extends object] ?
    readonly ["object", { [K in keyof S]: ValidateSchema<S[K]> }] :
    never;

function validate<const T extends AnySchema>(schema: T & ValidateSchema<T>) {
    return []
}

validate(schema1)
validate(schema3)
validate(["object", {
    name: ["string", { required: true }],
}])

Playground link

Maybe I made different decisions from the ones you would make, but at no point here am I seeing something that looks like a bug in the language. You might want to close this issue and ask questions in Discord or Stack Overflow if you can't figure out how to adapt this to your use case.

@RyanCavanaugh
Copy link
Member

It seems like what you want is this

type StringConstraints = { required?: boolean }
type ObjectConstraints = { required?: boolean }

type StringSchema = ["string", StringConstraints]
type ObjectSchema = ["object", Record<string, AnySchema>]

type SchemaFor<T> = 
    [T] extends [string | null | undefined] ? StringSchema :
    [T] extends [object | null | undefined] ? ObjectSchema :
    never

type AnySchema = StringSchema | ObjectSchema

because the T in ObjectSchema isn't really doing anything in a way that works.

There are a lot of schema-like libraries doing stuff like this correctly, e.g. zod, and I'd recommend reading their source to see how to do this.

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Oct 14, 2024
@fwolff
Copy link
Author

fwolff commented Oct 15, 2024

Thank you both for your quick replies.

@jcalz your ValidateSchema was the key.
@RyanCavanaugh I will check the zod library.

Closing the issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

3 participants