Reputation: 1512
I have this validation file
interface Rule {
required?: true;
isNumber?: true;
isDate?: {
format: string;
}
// ... some other rules
// ruleNames: RuleOptions
}
const RuleMap: {
[key in keyof Required<Rule>]: (
value: any,
options: Required<Rule>[key]
) => boolean;
} = {
required: (value) => value != null,
isNumber: (value) => isNumber(value),
isDate: (value, options) => isDate(value, options),
//...otherRules
}
// now I have a fairly strong typed object RuleMap
RuleMap.isDate('foo', { format: "DD/MM/YYYY" }); // no problem
RuleMap.required('foo', { format: "DD/MM/YYYY" }); // throws error
But when I try to do this
const myRules: Rule = {
isNumber: true
}
(Object.keys(myRules) as (keyof Rule)[]).forEach((ruleName) => {
// Expect this to be working
RuleMap[ruleName](someValue, myRules[ruleName]);
// This throws error
// 'true | { format: string }' is not assignable to type '(true & { format: string })'
});
e.g:
When specifying isDate
, RuleMap
enforce me to input the exact isDate
's option for the function.
When looping for Rule
, I expect the typescript to know that I am passing the exact ruleName
's ruleOption to the function.
Is it possible? If so, how should I do it?
Upvotes: 1
Views: 100
Reputation: 20162
The issue is exactly in stating that myRules
is Rule
, it means that TS doesn't know really what fields are in this object at the compile time, it treats it as Rule
, so fields can or not be there.
Your loop is typed as it would go through all keys from Rule
not from typeof myRules
, in reality part of them are not there, but type system doesn't see that. At the value level it is obvious, that you go through myRules
and if you do so field is there, but for type level myRules
is just Rule
and has keys for which value can be undefined. This is exactly the same what is happening in below simplified example:
const ruleName: "required" | "isNumber" | "isDate" = 'isDate';
const value = myRules[ruleName]; // value can be undefined!
The above is exactly what is happening inside forEach
keys are typed as keys of Rule
and value can be undefined
what is in conflict with requirements of called function, requirement is stated as - options: Required<Rule>[key]
, so it doesn't allow undefined
. And here comes an error.
Ok so what can we do about that? The simple solution is to just work with direct type of myRules
by using const
:
const myRules = {
isNumber: true
} as const; // pay attention here
(Object.keys(myRules) as (keyof typeof myRules)[]).forEach((ruleName) => {
const value = 1; // example
RuleMap[ruleName](value, myRules[ruleName]);
});
Works without errors because ruleName
is always in the type, so myRules[ruleName]
is never undefined. const
allows type system to work with exactly the same shape of the object you have in runtime.
I assume you need it in some more real enviroment. In such we need to have generic which will allow as to work with exact narrowed type of Rule
interface. Consider below example:
type OnlyKeysInRule<T extends Rule> = {
[K in keyof Rule]: T[K]
} & {
[K in Exclude<keyof T, keyof Rule>]: never
}
function doStaff<R extends Rule>(rules: OnlyKeysInRule<R>) {
(Object.keys(rules) as (keyof OnlyKeysInRule<R> & keyof typeof RuleMap)[]).forEach((ruleName) => {
const value = 1; // example
RuleMap[ruleName](value, rules[ruleName]);
});
}
doStaff(myRules) // is ok
doStaff({isNumber: true, isHelicopter: false}) // error as expected - `isHelicopter` is not define in RuleMap
Explanation:
OnlyKeysInRule
is exclusive map which says - you can pass object which has only fields in Rule
. Why it is important - because R extends Rule
means type R can have additional fields, and that means RuleMap[ruleName]
is not safe operation, as ruleName
can be additional field not existing in RuleMap
, this type prevents such.as (keyof OnlyKeysInRule<R> & keyof typeof RuleMap)
we say we loop over only through keys in both RuleMap
and R
. Here TS is not able to infer that OnlyKeysInRule
is already enough to prevent that, that is why union needs to be used.Upvotes: 1