I am building a type safe form schema. One of the form entries needs perform keyof checking of a subset of the form type. I am at a loss how to pass & narrow the generic type to the sub type.
Here is a link to ts playground where I have tried to work out a simple version of what I am working on. playground
Specifically I want the fields properties of the FieldArray to be type safe. The same way that the Schema type is. I am at a loss as to how to narrow or even pass the type for fields
type FieldType = "text-input" | "number" | "dropdown" | "checkbox";
type Field = {
label: string;
type: FieldType;
};
type FieldName<T> = T[keyof T] extends (infer I)[] ? I : never;
type FieldArray<T> = {
type: "array";
groupLabel: string;
fields: Record<keyof FieldName<T> & string, Field>;
};
type SchemaField<T> = Field | FieldArray<T>;
type Schema<T> = Record<keyof T, SchemaField<T>>;
type Form = {
workflowName: string;
id: number;
rules: { ruleName: string; isActive: boolean; ruleId: number }[];
errors: { errorName: string; isActive: boolean; errorId: number }[];
};
const formSchema: Schema<Form> = {
workflowName: { type: "text-input", label: "Name" },
id: { type: "number", label: "Id" },
rules: {
type: "array",
groupLabel: "Rules",
fields: {
ruleName: { label: "Rule Name", type: "text-input" },
isActive: { label: "Is Active", type: "checkbox" },
ruleId: { label: "Rule Id", type: "number" },
},
},
errors: {
type: "array",
groupLabel: "Errors",
fields: {
errorName: { label: "Error Name", type: "text-input" },
isActive: { label: "Is Active", type: "checkbox" },
errorId: { label: "Error Id", type: "number" },
},
},
};
I expect the fields property of rules and errors to be type safe against their definition in Form
Here's an example of how this is not type safe. In the above I can put any properties in the fields object of errors
the fields object for errors should only be allowed to contain the keys errorName, isActive, errorId as per the definition. But the below entries do not trigger a type warning.
errors: {
fields: {
foo: { label: "Error Name", type: "text-input" },
bar: { label: "Is Active", type: "checkbox" },
baz: { label: "Error Id", type: "number" },
},
},
Given your code example, my inclination would be to define
Schema<T>as follows:Instead of using the
Record<K, V>utility type in which there is no correlation between the particular keys inKand the corresponding values inV, I've made it a structure-preserving mapped type where each keyKinkeyof Tis mapped to the property typeSchemaProp<T[K]>.And
SchemaProp<T>takes a property typeTand converts it into the corresponding "field" type. It checks, via conditional type, whetherTis an array with element typeU; if so, then it becomes an"array"-typed field where thefieldsproperty is itself aSchema<U>. ThereforeSchema<T>is a recursive type, meaning that, if you wanted, you could represent schemas of types which contain arrays of arrays, or arrays of arrays of arrays. If the propertyTis not an array, then you get just one of theFieldType-typed fields.Let's make sure this works:
So that works, and if we put the wrong keys in for one of the
fieldsproperties, we'll see an appropriate error:Note that, depending on your use cases, you might want to modify that definition.
Maybe you will have an object type with a property that is another object type instead of an array of object types. If so, you'd probably need another conditional check in
SchemaProp<T>. Or maybe you will have a property whose type is a union, in which case you'd probably need to make sure thatSchemaProp<T>distributes over such unions... or doesn't distribute, depending on your needs.Thorough testing is recommended.
Playground link to code