Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Issue with generic properties when type mapping

I have a library which exports a utility type similar to the following:

type Action<Model extends object> = (data: State<Model>) => State<Model>;

This utility type allows you to declare a function that will perform as an "action". It receives a generic argument being the Model that the action will operate against.

The data argument of the "action" is then typed with another utility type that I export;

type State<Model extends object> = Omit<Model, KeysOfType<Model, Action<any>>>;

The State utility type basically takes the incoming Model generic and then creates a new type where all the properties that are of type Action have been removed.

For e.g. here is a basic user land implementation of the above;

interface MyModel {
  counter: number;
  increment: Action<Model>;
}

const myModel = {
  counter: 0,
  increment: (data) => {
    data.counter; // Exists and typed as `number`
    data.increment; // Does not exist, as stripped off by State utility 
    return data;
  }
}

The above is working very well. 👍

However, there is a case that I am struggling with, specifically when a generic model definition is defined, along with a factory function to produce instances of the generic model.

For example;

interface MyModel<T> {
  value: T; // 👈 a generic property
  doSomething: Action<MyModel<T>>;
}

function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    doSomething: data => {
      data.value; // Does not exist 😭
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

In the example above I expect the data argument to be typed where the doSomething action has been removed, and the generic value property still exists. This however is not the case - the value property has also been removed by our State utility.

I believe the cause for this is that T is generic without any type restrictions/narrowing being applied to it, and therefore the type system decides that it intersects with an Action type and subsequently removes it from the data argument type.

Is there a way to get around this restriction? I have done some research and was hoping there would be some mechanism in which I could state that T is any except for an Action. i.e. a negative type restriction.

Imagine:

function modelFactory<T extends any except Action<any>>(value: T): UserDefinedModel<T> {

But that feature doesn't exist for TypeScript.

Does anyone know of a way I could get this to work as I expect it to?


To aid debugging here is a full code snippet:

// Returns the keys of an object that match the given type(s)
type KeysOfType<A extends object, B> = {
  [K in keyof A]-?: A[K] extends B ? K : never
}[keyof A];

// Filters out an object, removing any key/values that are of Action<any> type
type State<Model extends object> = Omit<Model, KeysOfType<Model, Action<any>>>;

// My utility function.
type Action<Model extends object> = (data: State<Model>) => State<Model>;

interface MyModel<T> {
  value: T; // 👈 a generic property
  doSomething: Action<MyModel<T>>;
}

function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    doSomething: data => {
      data.value; // Does not exist 😭
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

You can play with this code example here: https://codesandbox.io/s/reverent-star-m4sdb?fontsize=14

like image 955
ctrlplusb Avatar asked Nov 06 '19 21:11

ctrlplusb


3 Answers

This is an interesting problem. Typescript can't generally do much with regard to generic type parameters in conditional types. It just defers any evaluation of extends if it finds that the evaluation involves a type parameter.

An exception applies if we can get typescript to use a special kind of type relation, namely, an equality relation (not an extends relation). An equality relation is simple to understand for the compiler, so there is no need to defer the conditional type evaluation. Generic constraints are one of the few places in the compiler where type equality is used. Let's look at an example:

function m<T, K>() {
  type Bad = T extends T ? "YES" : "NO" // unresolvable in ts, still T extends T ? "YES" : "NO"

  // Generic type constrains are compared using type equality, so this can be resolved inside the function 
  type Good = (<U extends T>() => U) extends (<U extends T>() => U) ? "YES" : "NO" // "YES"

  // If the types are not equal it is still un-resolvable, as K may still be the same as T
  type Meh = (<U extends T>()=> U) extends (<U extends K>()=> U) ? "YES": "NO" 
}

Playground Link

We can take advantage of this behavior to identify specific types. Now, this will be an exact type match, not an extends match, and exact type matches are not always suitable. However, since Action is just a function signature, exact type matches might work well enough.

Lets see if we can extract types that match a simpler function signature such as (v: T) => void:

interface Model<T> {
  value: T,
  other: string
  action: (v: T) => void
}

type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

function m<T>() {
  type M = Model<T>
  type KeysOfIdenticalType = {
    [K in keyof M]: Identical<M[K], (v: T) => void, never, K>
  }
  // Resolved to
  // type KeysOfIdenticalType = {
  //     value: Identical<T, (v: T) => void, never, "value">;
  //     other: "other";
  //     action: never;
  // }

}

Playground Link

The above type KeysOfIdenticalType is close to what we need for filtering. For other, the property name is preserved. For the action, the property name is erased. There is just one pesky issue around value. Since value is of type T, is is not trivially resolvable that T, and (v: T) => void are not identical (and in fact they may not be).

We can still determine that value is identical to T: for properties of type T, intersect this check for (v: T) => void with never. Any intersection with never is trivially resolvable to never. We can then add back properties of type T using another identity check:

interface Model<T> {
  value: T,
  other: string
  action: (v: T) => void
}

type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

function m<T>() {
  type M = Model<T>
  type KeysOfIdenticalType = {
    [K in keyof M]:
      (Identical<M[K], (v: T) => void, never, K> & Identical<M[K], T, never, K>) // Identical<M[K], T, never, K> will be never is the type is T and this whole line will evaluate to never
      | Identical<M[K], T, K, never> // add back any properties of type T
  }
  // Resolved to
  // type KeysOfIdenticalType = {
  //     value: "value";
  //     other: "other";
  //     action: never;
  // }

}

Playground Link

The final solution looks something like this:

// Filters out an object, removing any key/values that are of Action<any> type
type State<Model extends object, G = unknown> = Pick<Model, {
    [P in keyof Model]:
      (Identical<Model[P], Action<Model, G>, never, P> & Identical<Model[P], G, never, P>)
    | Identical<Model[P], G, P, never>
  }[keyof Model]>;

// My utility function.
type Action<Model extends object, G = unknown> = (data: State<Model, G>) => State<Model, G>;


type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

interface MyModel<T> {
  value: T; // 👈 a generic property
  str: string;
  doSomething: Action<MyModel<T>, T>;
  method() : void
}


function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    str: "",
    method() {

    },
    doSomething: data => {
      data.value; // ok
      data.str //ok
      data.method() // ok 
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

/// Still works for simple types
interface MyModelSimple {
  value: string; 
  str: string;
  doSomething: Action<MyModelSimple>;
}


function modelFactory2(value: string): MyModelSimple {
  return {
    value,
    str: "",
    doSomething: data => {
      data.value; // Ok
      data.str
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

Playground Link

NOTES: The limitation here is that this only works with one type parameter (although it can possibly be adapted to more). Also, the API is a bit confusing for any consumers, so this might not be the best solution. There may be issues that I have not identified yet. If you find any, let me know 😊

like image 168
Titian Cernicova-Dragomir Avatar answered Nov 20 '22 07:11

Titian Cernicova-Dragomir


It would be great if I could express that T is not of type Action. Sort of an inverse of extends

Exactly like you said, the problem is we don't have negative constraint yet. I also hope they can land such feature soon. While waiting, I propose a workaround like this:

type KeysOfNonType<A extends object, B> = {
  [K in keyof A]-?: A[K] extends B ? never : K
}[keyof A];

// CHANGE: use `Pick` instead of `Omit` here.
type State<Model extends object> = Pick<Model, KeysOfNonType<Model, Action<any>>>;

type Action<Model extends object> = (data: State<Model>) => State<Model>;

interface MyModel<T> {
  value: T;
  doSomething: Action<MyModel<T>>;
}

function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    doSomething: data => {
      data.value; // Now it does exist 😉
      data.doSomething; // Does not exist 👍
      return data;
    }
  } as MyModel<any>; // <-- Magic!
                     // since `T` has yet to be known
                     // it literally can be anything
}
like image 41
hackape Avatar answered Nov 20 '22 06:11

hackape


count and value will always make the compiler unhappy. To fix it you might try something like this:

{
  value,
  count: 1,
  transform: (data: Partial<Thing<T>>) => {
   ...
  }
}

Since Partial utility type is being used, you will be ok in the case transform method is not present.

Stackblitz

like image 1
Lucas Avatar answered Nov 20 '22 06:11

Lucas