Sytten
Sytten

Reputation: 320

Combining Typescript generics with any without losing type

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

Answers (1)

Sytten
Sytten

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

Related Questions