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
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 😊
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
}
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With