Reputation: 320
This is related to a plugin I am building for the @nexus/schema library (type-safe GraphQL), but it is purely a Typescript typing issue.
I have a rules system where all my rules are derived form this interface:
interface Rule<Type extends string, Field extends string> {
resolve(root: RootValue<Type>, args: ArgsValue<Type, Field>): boolean;
}
Note: The RootValue
and ArgsValue
are types used to fetch the "real" generated type or return any
, this is a trick nexus uses to type everything without the use needing to explicitly specify the type.
See this link for the source code.
The two most basic are:
type Options = { cache?: boolean }
type RuleFunc<Type extends string, Field extends string> =
(root: RootValue<Type>, args: ArgsValue<Type, Field>) => boolean;
class BaseRule<Type extends string, Field extends string> implements Rule<Type, Field> {
constructor(private options: Options, private func: RuleFunc<Type, Field>) {}
resolve(root: RootValue<Type>, args: ArgsValue<Type, Field>) {
// Do stuff with the options
const result = this.func(root, args)
return result
}
}
class AndRule<Type extends string, Field extends string> implements Rule<Type, Field> {
constructor(private rules: Rule<Type, Field>[]) { }
resolve(root: RootValue<Type>, args: ArgsValue<Type, Field>) {
return this.rules
.map(r => r.resolve(root, args))
.reduce((acc, val) => acc && val)
}
}
I then define helpers:
const rule = (options?: Options) =>
<Type extends string, Field extends string>(func: RuleFunc<Type, Field>): Rule<Type, Field> => {
options = options || {};
return new BaseRule<Type, Field>(options, func);
};
const and = <Type extends string, Field extends string>(...rules: Rule<Type, Field>[]): Rule<Type, Field> => {
return new AndRule(rules)
}
My problem is that I need to be able to support generic rules that apply to all types/fields and specific rules only for one type/field. But if I combine a generic rule with a specific rule, the resulting rule is a Rule<any, any>
which then allows bad rules to be accepted.
const genericRule = rule()<any, any>((root, args) => { return true; })
const myBadRule = rule()<"OtherType", "OtherField">((root, args) => {
return true;
})
const myRule: Rule<"Test", "prop"> = and(
rule()((root, args) => {
return false
}),
genericRule,
myBadRule // THIS SHOULD BE AN ERROR
)
I am guessing that has to do in part with the lack of existential typing in Typescript that basically forces me to use a any
in the first place, but is there a workaround that I could use to prevent the type the any
from overriding my type. One workaround I found is to explicitly type the and
, but that is not nice from a usability perspective.
EDIT 2: I created a playground with a simplified version so it easier to view the problem.
EDIT 3: As pointed out in the comments, never
works with the previous example. I thus created this example for which never
does not work. Also I reworked the issue so that all information is inside the issue for posterity. I also found that the reason never
cannot be used is because of the ArgsValue
type.
Thanks a lot!
EDIT 1:
I found a workaround, though it requires a change in the interface:
export interface FullRule<
Type extends string,
Field extends string
> {
resolve(
root: RootValue<Type>,
args: ArgsValue<Type, Field>,
): boolean;
}
export interface PartialRule<Type extends string>
extends FullRule<Type, any> {}
export interface GenericRule extends FullRule<any, any> {}
export type Rule<Type extends string, Field extends string> =
| FullRule<TypeName, FieldName>
| PartialRule<TypeName>
| GenericRule;
With and
becoming:
export const and = <Type extends string, Field extends string>(
...rules: Rule<Type, Field>[]
): FullRule<Type, Field> => {
return new RuleAnd<Type, Field>(rules);
};
The and
returns a properly typed FullRule<'MyType','MyField'>
and will thus reject the badRule
. But it does require that I add new methods to create partial and generic rules.
Upvotes: 2
Views: 645
Reputation: 320
Thanks to @jcalz I was able to understand a few more things about the typing system of Typescript and basically realize even if I was able to make the contravariance work with the complex helpers of nexus, it would not achieve what I wanted to do.
So I took another approach instead. It is not perfect but works well enough. I defined two new operators:
export const generic = (rule: Rule<any, any>) => <
Type extends string,
Field extends string
>(): Rule<Type, Field> => rule;
export const partial = <Type extends string>(rule: Rule<Type, any>) => <
T extends Type, // NOTE: It would be best to do something with this type
Field extends string
>(): Rule<Type, Field> => rule;
With those, the returned type becomes a generic function. When you call the function in the "typed context", it will prevent the propagation of any
.
const genericRule = generic(rule()((root, args) => { return true; }))
const myBadRule = rule()<"OtherType", "OtherField">((root, args) => {
return true;
})
const myRule: Rule<"Test", "prop"> = and(
rule()((root, args) => {
return false
}),
genericRule(), // Returns a Rule<"Test", "prop">
myBadRule // ERROR
)
It is not perfect in the sense that mixing partial and generic rules is quite verbose and the type needs to be specified in the parent helper:
const myPartialType = partial<'Test'>(
rule()((root, _args, ctx) => {
return true;
})
);
const myCombination = partial<'Test'>(
chain(
isAuthenticated(),
myPartialType()
)
);
I still feel like this is somewhat of a hack, so I am still open to suggestions and better solution.
Upvotes: 1