2

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.

Sytten
  • 322
  • 1
  • 8
  • Please consider turning the example code into a [mcve] that can be dropped into a standalone IDE like [The TypeScript Playground](https://www.typescriptlang.org/play/) to demonstrate what you're seeing. Or maybe add a link to an existing web IDE that has the library dependencies already taken care of? – jcalz Jun 13 '20 at 19:56
  • It is somewhat hard to show in codesanbox honestly I really tried, the best would be to run my example here: https://github.com/Sytten/nexus-shield/tree/master/examples/simple – Sytten Jun 13 '20 at 21:15
  • I don't know that you can expect others to install projects on their local system; I personally am unlikely to do this. – jcalz Jun 13 '20 at 21:28
  • @jcalz I created a playground with a simplified version of the problem (see the edit in the post). Sorry for being an asshole in my previous response. I would really appreciate if you can take a look! – Sytten Jun 14 '20 at 18:01
  • Please provide all information directly here, not via extrnal links. – Yunnosch Jun 14 '20 at 18:07
  • For that example, since your generics are in contravariant position, I'd try using `never` instead of `any` and seeing if that works. If not, please elaborate on the issue. – jcalz Jun 14 '20 at 18:11
  • If that doesn't work I'd look into making `genericRule` actually *generic* in the TS sense. Will circle back later – jcalz Jun 14 '20 at 18:23
  • Agreed never would work in this example, but in my real code it says type "MyType" is not assignable to type never. So I will rework my example. @Yunnosch all information is provided in the issue, not sure what you mean. – Sytten Jun 14 '20 at 18:24
  • @jcalz Ok my new example is showing exactly what is wrong. It has to do with the ArgsValue type. – Sytten Jun 14 '20 at 19:21
  • 1
  • It just happens [naturally](https://www.typescriptlang.org/play/index.html#code/C4TwDgpgBASgrgGwgHgCoBooFUB8UC8UAFAB4BcUGUIFWAlAXgEYD2LSAhgHYDcAsAChBAYxZcAzsCgBzCFwgAnAJbD4SCmpSTlXaZm1LdeQqUwgG+PMAVwIIsZKgALCAgQtNGxCgBELtyw+mD7ALgoQPsbEJGYWeABmHAjidkICohJS3AAmBFBomLhEAHSlHF5IBdg4ANoAunHRFFQ02I0cxRAAboogRMKMUMKm1HR0-GkZjiwA1hwgeTnIfq7uQVAhYRE4RLLyyqremP7umuNQAPQXULPz9plQTBy5hEs+0mzZTCARwVuRuzkihUmmOqw83nOVygigULAUAEJBFBBEA) if `Rule` is considered contravariant in `A` and `B`. – jcalz Jun 15 '20 at 01:27
  • Those nexus helpers are indeed quite complex and I don't have a lot of control over them since they are part of the framework (the source version is easier to read, I posted the link in the issue) :( I sent the issue to the devs to see if they have time to take a look and maybe they can make improvements to the helpers to help the compiler. Otherwise the workaround 1 of using a union "works" since the compiler seems confused and just ignores the `any` (not sure it will hold long term though). – Sytten Jun 15 '20 at 02:43
  • @jcalz I think I managed to get the contravariance working again? The issue lies in the last protection `? K3 extends keyof GenTypes[K][K2]`. I have no idea why though. If you remove it (and the corresponding `any`), it does no complain about `string` or `never` not being assignable to type `"Test"`. Though it is still not perfect because `and` becomes a `Rule` and accepts a bad rule (it does work correctly with `never` though). – Sytten Jun 15 '20 at 03:34

1 Answers1

1

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.

Sytten
  • 322
  • 1
  • 8