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:
type Schema<T> = { [K in keyof T]: SchemaProp<T[K]> };
type SchemaProp<T> = T extends readonly (infer U)[] ?
{ type: "array", groupLabel: string, fields: Schema<U> }
: { type: FieldType, label: string }
Instead of using the Record<K, V>
utility type in which there is no correlation between the particular keys in K
and the corresponding values in V
, I've made it a structure-preserving mapped type where each key K
in keyof T
is mapped to the property type SchemaProp<T[K]>
.
And SchemaProp<T>
takes a property type T
and converts it into the corresponding "field" type. It checks, via conditional type, whether T
is an array with element type U
; if so, then it becomes an "array"
-typed field where the fields
property is itself a Schema<U>
. Therefore Schema<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 property T
is not an array, then you get just one of the FieldType
-typed fields.
Let's make sure this works:
const goodFormSchema: 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" }
}
}
}; // okay
So that works, and if we put the wrong keys in for one of the fields
properties, we'll see an appropriate error:
const badFormSchema: 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: {
foo: { label: "Error Name", type: "text-input" }, // error!
// Object literal may only specify known properties, and 'foo' does not exist in type ...
bar: { label: "Is Active", type: "checkbox" },
baz: { label: "Error Id", type: "number" },
}
}
};
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 that SchemaProp<T>
distributes over such unions... or doesn't distribute, depending on your needs.
Thorough testing is recommended.