Example interface.
interface Model {
id: number;
name: string;
address: {
street: string;
zip: number;
post: string;
country: 'US' | 'GB' | 'IE' | 'CA' | 'AU';
}
}
I have a component that displays the details of some object instance (in this case it will be an instance of Model
). This component requires the object model instance and a definition that describes how individual properties should be displayed.
interface BaseDefinition {
label: string;
}
interface SimpleDefinition<T> extends BaseDefinition {
property: Paths<T>;
}
interface FormattedGeneralDefinition<T> extends BaseDefinition {
format: (value: T) => string;
}
interface FormattedSpecificDefinition<T, TPath in Paths<T>> extends BaseDefinition {
property: TPath;
format: (value: TypeofPath<T, TPath>, instance: T) => string;
}
type Definition<T> = SimpleDefinition<T> | FormattedGeneralDefinition<T> | {[K in Paths<T>]: FormattedSpecificDefinition<T, K>}[Paths<T>];
For Model
this equates to expected union of:
type Definition<Model> = {
label: string;
field: "id" | "name" | "address" | "address.street" | "address.zip" | "address.post" | "address.country";
} | {
label: string;
format: (instance: Model) => string;
} | {
label: string;
field: "id";
format: (value: number, instance: Model) => string;
} | {
label: string;
field: "name";
format: (value: string, instance: Model) => string;
} | {
label: string;
field: "address";
format: (value: {
street: string;
zip: number;
post: string;
country: 'CH' | 'SI' | 'AT' | 'DE';
}, instance: Model) => string;
} | {
label: string;
field: "address.street";
format: (value: string, instance: Model) => string;
} | {
label: string;
field: "address.zip";
format: (value: number, instance: Model) => string;
} | {
field: "address.post";
format: (value: string, instance: Model) => string;
label: string;
} | {
label: string;
field: "address.country";
format: (value: "CH" | "SI" | "AT" | "DE", instance: Model) => string;
}
The Paths<T>
type is a utility type that resolves to a union of all dot delimited property paths of a given type (like a deep keyof
with dot delimited notation - implementation also on Stackoverflow).
In the case of this example the Paths would resolve to a union of all property paths:
Paths<Model> = 'id' | 'name' | 'address' | 'address.street' | 'address.zip' | 'address.post' | 'address.country';
The TypeofPaths<T, TPath>
type is a type I've written that returns the type of a model property as selected using the dot-delimited string.
In the case of this example the TypeofPath<Model, 'address.country'> would resolve to:
TypeofPath<Model, 'address.country'> = 'US' | 'GB' | 'IE' | 'CA' | 'AU';
This type is defined as
type TypeofPath<T extends Record<keyof T, unknown>, TPath extends Paths<T>> = TPath extends keyof T
? T[TPath]
: TPath extends `${infer TLeft extends Extract<keyof T, string | number>}.${infer TRight}`
? TRight extends Paths<T[TLeft]>
? TypeofPath<T[TLeft], TRight>
: never
: never;
The problem
When I try to define my definition object with format
function, definition type doesn't seem to be inferred correctly by the provided properties and their values:
const d: Definition<Model>[] = [{
// this one works as expected
label: 'Simple',
field: 'address.country'
},{
label: 'Formatted General',
// Parameter 'model' implicitly has an 'any' type.(7006)
// 'model' should be of type 'Model'
format: (model) => `Country is ${model.address.country}`
},{
label: 'Formatted Specific',
field: 'address.zip',
// Parameter 'zip' implicitly has an 'any' type.(7006)
// 'zip' should be of type 'number'
// Parameter 'model' implicitly has an 'any' type.(7006)
// 'model' should be of type 'Model'
format: (zip, model) => `Zip is ${zip} and country is ${model.address.country}`
}];
I've feel I'm so close to solution, but I don't know what exactly I'm missing so that the definition object types don't implicitly get resolved. Apparently it seems that my Definition is not a full discriminated union of types because otherwise I suppose this should work. If course if I explicitly cast them, then everything works as expected.
const d: Definition<Model>[] = [{
label: 'Simple',
field: 'address.country'
},{
label: 'Formatted General',
format: (model) => `Country is ${model.address.country}`
} as FormattedGeneral<Model>,{
label: 'Formatted Specific',
field: 'address.zip',
format: (zip, model) => `Zip is ${zip} and country is ${model.address.country}`
} as FormattedSpecific<Model, 'address.zip'>];
Naturally, I don't want explicit casting as field
property should already provide all the information to resolve the correct instance type.
You're welcome to test these types in the Typescript playground. There's an additional type in there that helps with type resolution of the
Definition<T>
type.