Cloud Soh Jun Fu
Cloud Soh Jun Fu

Reputation: 1512

Typescript loop typed object

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

Answers (1)

Maciej Sikora
Maciej Sikora

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

Related Questions