I want to have the default
property correctly infer the ArkType Type from the schema
property, infinitely deep and infinitely wide. Accomplishing simple inference was easy... with the use of a creation function to provide the generics (as opposed to defining a literal), and a bit of generic hoisting to satisfy the compiler - it works.
Making it recursive was simper than I thought too, all I needed to do was carry the DataType
for the child in the parent object.
However, I'm having a hard time wrapping my head around the instantiation of typed/inferred records with a separate generic type for each key.
I'm pretty sure:
as const
, and using that as the type... (this is the concept I'm going for, but with structural constraints & narrowed to an ArkType Type
)I have tried everything related to (keywords):
DataType
of each keyimport { Type, type } from "arktype"
export type DataConstraintOption<DataType extends Type, InnerDataType extends Type> = {
name: string
description: string
required: boolean
} & (
| {
type: "group"
options: DataConstraintOptions<InnerDataType>
}
| {
type: "value"
schema: DataType
default?: DataType["infer"]
}
)
export type DataConstraintOptions<DataType extends Type, InnerDataType extends Type = Type> = Record<
string,
DataConstraintOption<DataType, InnerDataType>
>
function createDataConstraintOptions<DataType extends Type, InnerDataType extends Type>(
props: DataConstraintOptions<DataType, InnerDataType>
): DataConstraintOptions<DataType, InnerDataType> {
return props
}
export const test = createDataConstraintOptions({
test: {
name: "GOOD: Works as Intended",
description: "`default` should error on `false`, as it is correctly inferred as a string.",
type: "value",
required: true,
schema: type("string"),
default: false
},
siblingTest: {
name: "BAD: Inherits `DataType` generic from `test`",
description: "`schema` should not error, and `default` should not be inferred as a string.",
type: "value",
required: true,
schema: type("boolean"),
default: true
},
groupTest: {
name: "GOOD: Works as Intended",
description: "Groups allow nested options.",
type: "group",
required: true,
options: {
nestedTest: {
name: "GOOD: Works as Intended",
description: '`default` should error on `"test"`, as it is correctly inferred as a boolean.',
type: "value",
required: true,
schema: type("boolean"),
default: "test"
},
nestedSiblingTest: {
name: "BAD: Inherits `DataType` generic from `nestedTest`",
description: "`schema` should not error, and `default` should not be inferred as a boolean.",
type: "value",
required: true,
schema: type("number"),
default: 1
}
}
}
})
Can this be done with variadic generics? Or is this just impossible to do in TypeScript?
I could define this as a literal and use satisfies
to verify the type without losing the specificity, but this is more about DX - the inference is critical here.
From a contextual standpoint: I could just make this type unknown
and verify it at runtime (what I'm doing in the meantime)... but unless this needs to be serialized (for use outside of TS), there's no reason for that. Might as well just use JavaScript at that point.
EDIT: I modified the code to demonstrate the problem without any dependencies, as this has no relevance to ArkType besides additional context. For that sake, I am keeping the above. Here is a TypeScript-only reproduction:
export type PlaceholderWideType = string | number | boolean
export type DataConstraintOption<DataType extends PlaceholderWideType, InnerDataType extends PlaceholderWideType> = {
name: string
description: string
required: boolean
} & (
| {
type: "group"
options: DataConstraintOptions<InnerDataType>
}
| {
type: "value"
/**
* The `default` property always needs to be the same type as `schema` (or vice-versa - however since `schema` is defined first here, it will set the generic first), otherwise we should get a type error.
*/
schema: DataType
default?: DataType
}
)
export type DataConstraintOptions<
DataType extends PlaceholderWideType,
InnerDataType extends PlaceholderWideType = PlaceholderWideType
> = Record<string, DataConstraintOption<DataType, InnerDataType>>
function createDataConstraintOptions<DataType extends PlaceholderWideType, InnerDataType extends PlaceholderWideType>(
props: DataConstraintOptions<DataType, InnerDataType>
): DataConstraintOptions<DataType, InnerDataType> {
return props
}
export const test = createDataConstraintOptions({
test: {
name: "GOOD: Works as Intended",
description: "`test.default` should error on `false`, as it infers a string type from `test.schema`.",
type: "value",
required: true,
schema: "anything",
default: false
},
siblingTest: {
name: "BAD: Inherits `DataType` Generic From `test` Record",
description: "Neither `siblingTest.schema` nor `siblingTest.default` should inherit the type from `test.schema`.",
type: "value",
required: true,
schema: true,
default: true
},
groupTest: {
name: "GOOD: Works as Intended",
description: "Groups allow nested options.",
type: "group",
required: true,
options: {
nestedTest: {
name: "GOOD: Works as Intended",
description:
'`nestedTest.default` should error on `"anything"`, as it infers a boolean type from `nestedTest.schema`.',
type: "value",
required: true,
schema: true,
default: "anything"
},
nestedSiblingTest: {
name: "BAD: Inherits `DataType` Generic From `test` Record",
description:
"Neither `nestedSiblingTest.schema` nor `nestedSiblingTest.default` should inherit the type from `nestedTest.schema`.",
type: "value",
required: true,
schema: 1,
default: 1
}
}
}
})
You are trying to get TypeScript to infer generic arguments DataType
and InnerDataType
from a props
argument of type DataConstraintOptions<DataType, InnerDataType>
where DataConstraintOptions
is a recursive type that depends on its type parameters in a somewhat complicated way. But TypeScript's inference algorithm isn't really able to infer T
from an arbitrary F<T>
. There are some supported patterns, but it's easy to step outside of them.
Instead of doing it this way, I'd suggest refactoring so that the function is only generic in the type T
of the props
argument. This will almost always succeed. Then your goal is to validate T
via a constraint ValidateProps<T>
such that T extends ValidateProps<T>
if and only if T
conforms to your requirements. A secondary goal is: when T
does not conform to your requirements, you want ValidateProps<T>
the "closest" valid type to T
(for some subjective definition of "closest"). This way callers of the function will get helpful errors when they make a mistake.
That's the general approach. I will present an example of this approach below, but of course it might differ from what you need in your actual use case.
Here are some named types to help with our task:
interface BaseDataConstraintOption { name: string, description: string, required: boolean }
interface ValueDataConstraintOption extends BaseDataConstraintOption {
type: "value", schema: PlaceholderWideType, default?: PlaceholderWideType
}
interface GroupDataConstraintOption extends BaseDataConstraintOption {
type: "group", options: { [k: string]: BaseDataConstraintOption }
}
You had types similar to this already. Now the goal is to write ValidateProps<T>
such that
function createDataConstraintOptions<T extends ValidateProps<T>>(
props: T): T { return props }
works. Note that it's not always possible to write a recursive constraint like that; sometimes TypeScript will balk and you'd need to move the constraint-like behavior into the argument type itself (e.g., f<T extends object>(props: T & ValidateProps<T>)
). In this case it works.
We know that ValidateProps<T>
takes an object and we need to validate each property effectively independently, so let's write this as a mapped type:
type ValidateProps<T extends object> = { [K in keyof T]: ValidateProp<T[K]> }
We've delegated the actual work to ValidateProp<>
, which takes some BaseDataConstraintOption
(or maybe the union ValueDataConstraintOption | GroupDataConstraintOption
) and validates it. So let's write that:
type ValidateProp<T> =
T extends ValueDataConstraintOption ? (
Omit<ValueDataConstraintOption, "default"> & { default?: T["schema"] }
) : T extends GroupDataConstraintOption ? (
Omit<GroupDataConstraintOption, "options"> & { options: ValidateProps<T["options"]> }
) : ValueDataConstraintOption | GroupDataConstraintOption;
This is a conditional type that checks if T
is either a ValueDataConstraintOption
or a GroupDataConstraintOption
and then does the appropriate validation. It's actually a distributive conditional type so if T
happens to be a union it should hopefully do the right thing there.
If T
is a ValueDataConstraintOption
then you really only need to validate the default
property. Everything else is always going to be fine if it conforms to ValueDataConstraintOption
. So the validated version of T
is the same as T
except the default
property needs to match the type of the schema
property. For that we use the Omit
utility type to say "everything except default
" and then intersect it with {default?: T["schema"]}
, an object whose property is the indexed access type corresponding to the schema
property of T
.
If T
is a GroupDataConstraintOption
then you really only need to validate the options
property. Again, everything else is fine. So we use Omit<T, "options">
and intersect it with an object holding a validated options
type. And hey, we already have a type to validate that: ValidateProps
. So we use {options: ValidateProps<T["options">}
and there's our recursion.
If T
is neither a ValueDataConstraintOption
nor a GroupDataConstraintOption
then it's completely bad. In this case we have to bail out. The "closest" correct thing is probably just the union ValueDataConstraintOption | GroupDataConstraintOption
, so that's what we use (thanks to @inducingchaos in the comments).
Okay, let's test it out:
const test = createDataConstraintOptions({
test: {
name: "", description: "", type: "value", required: true,
schema: "anything", default: false // error as desired
},
siblingTest: {
name: "", description: "", type: "value", required: true,
schema: true, default: true // okay as desired
},
groupTest: {
name: "", description: "", type: "group", required: true,
options: {
nestedTest: {
name: "", description: '', type: "value", required: true,
schema: true, default: "anything" // error as desired
},
nestedSiblingTest: {
name: "", description: "", type: "value", required: true,
schema: 1, default: 1 // okay as desired
}
}
}
})
Looks good, you get the errors where you want them and only where you want them. Is it perfect? Probably not. The inferred type of T
might not be exactly what you want (I think you end up with ValidateProps<typeof options>
which forgets some stuff). You may well need to refactor or rewrite to meet your specific use cases. But this hopefully serves to demonstrate the general approach: instead of trying to infer a type argument from a recursive type, infer a type argument directly from a value and then compute or validate the related types you care about.