简体   繁体   中英

How to write generic helper functions for a generic type in TypeScript?

I'm having trouble understanding the interplay of TypeScript generics with defaults, and how to keep utility functions as widely constrained as possible. (This example will be contrived, but it makes it easy to show the issue in a small amount of code.)

In modeling a simple case where you have an "editor" of a given "value", you might express it like:

type Editor<V extends object = object> = {
    value: V
    set: (value: V) => void
}

The editor is generic so that you can further refine the type of the value. And I've added the default value to the generic argument so that you can more easily omit it for cases where you don't need to make it more restrictive.

Then you have an external helper function written to be generic to work with any editor (simplified):

const get = <E extends Editor>(editor: E): E['value'] => {
    return editor.value
}

But then in trying to write a factory for these editors, I run in to a TypeScript error:

const create = <V extends object = object>(value: V): Editor<V> => {
    const editor: Editor<V> = {
        value,
        set: (value: V) => {
            const prev = get(editor)
            // ...
        }
    }

    return editor
}

The error is:

Argument of type 'Editor<V>' is not assignable to parameter of type 'Editor<object>'.
  Types of property 'set' are incompatible.
    Type '(value: V) => void' is not assignable to type '(value: object) => void'.
      Types of parameters 'value' and 'value' are incompatible.
        Type 'object' is not assignable to type 'V'.
          'object' is assignable to the constraint of type 'V', but 'V' could be instantiated with a different subtype of constraint 'object'.(2345)

Why is that happening? It's confusing because get seems be as lenient as possible, so I'd assume it would cover all subtypes, but still TypeScript complains that the types might have a mismatch.

Here's a TypeScript Playground link .

The issue of the original playground is solved in this playground but I don't know if I've removed the feature you were exploring.

This code compiles and I think would be a basis for doing stuff...

type Editor<V extends object> = {
  value: V;
  set: (value: V) => void;
};

const get = <V extends object>(editor: Editor<V>): V => {
  return editor.value;
};

const create = <V extends object>(value: V): Editor<V> => {
  const editor: Editor<V> = {
    value,
    set: (value: V) => {
      const prev = get(editor);
      // ...
    },
  };

  return editor;
};

To illustrate the problem, you can recreate the original error from my working example by following along with these step by step changes in the playground.

First change the signature of get to this, which introduces a potentially narrowing type E...

const get = <V extends object, E extends Editor<V>>(editor: E): V => {
  return editor.value;
};

Then fix it by ensuring that the narrowing type is in fact exactly the type of the editor instance...

const prev = get<V,typeof editor>(editor);

...which clarifies that there was an additional loose type flying around that needed nailing down, compared to my working code which didn't have it.

Regards open questions about the interplay between Generics and Defaults and such you're not the only one. I have some heuristics and learnings...

A) Where a type is part of the composition of an argument, always include the type in the function definitions which include that argument, eliminate defaults everywhere and never default to any .

B) In practice I've found that Typescript almost basically always infers the types from the argument anyway, but only if they have been preserved by following A) such that they are present in the code path for Typescript to inspect.

This probably explains why my first interventions in your code was removing the defaults, the second was ensuring that any reference to Editor was always accompanied with a Generic V which could be inferred from it.

Oh and C) if a type doesn't need to be aliased (and potentially narrowed), don't add the narrowing 'alias', as you may create an alias that collides later.

So for example if V is all that's needed to be able to define a function signature passing an Editor, then don't create an alias E extends Editor<V> , or worse E extends Editor such that later signatures could unexpectedly introduce what amounts to an alias E2 extends Editor<V2> which may or may not be the same thing.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM