3

I'm trying to create a set of functions that operate on a generic T that must implement and interface IDocument. While this generally seems to work, it seems like TypeScript doesn't recognize that T must have the keys of IDocument.

Here is a minimal example:

interface IDocument
{
    _id: number;
}

function testGeneric<T>(v: { [P in keyof T]?: T[P] })
{ }

function testConstraint<T extends IDocument>(doc: T)
{
    // this works
    console.log(doc._id);

    // this works
    testGeneric<IDocument>({ _id: doc._id });

    // this fails
    // Argument of type '{ _id: number; }' is not assignable to parameter of type '{ [P in keyof T]?: T[P] | undefined; }'.
    testGeneric<T>({ _id: doc._id });
}

You can see this live on the TypeScript playground here.

I am confused as to why this doesn't work since it seems like in my testConstraint function that T will always have an _id key since it must implement IDocument. And in fact if I take a T parameter and access the _id property it works fine.

Note that the testGeneric function is in a library that I don't own, so I can't change that signature.

What am I doing wrong here? Do I need to use a different constraint to express that T must have each key that IDocument has?

Chad
  • 19,219
  • 4
  • 50
  • 73
  • Possible duplicate of [Why can't I return a generic 'T' to satisfy a Partial?](https://stackoverflow.com/questions/46980763/why-cant-i-return-a-generic-t-to-satisfy-a-partialt) – jcalz Dec 02 '19 at 16:56
  • See [this](http://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgJIBED2CCuBbCcZAbwChkLkB9YAEwC5kR8AjaAblIF9TSYcQCMMEwhkkAM5gA4oWjAEAHgAqAPgAUAN0bFkAbQAKyUMgDWEAJ6YYyZQF0A-I2WG7yLgEoS73nwFCRMUkwAGFRKSg4UDAVZAgAD0gQWgk0LFwCcA0vMkpxCClZEHklNXVdGgZkAEYABncPTh5SaOh4JFsACwgANWgLADFgKCl0-EIwOMTCFLTscaJcykrGau5SYLCQCKjwFW6+qEHh0fnMsGzOIA), someone could specify a `T` which narrows properties instead of just adding them – jcalz Dec 02 '19 at 16:58
  • Possible duplicate of [How to merge back a key with a type where the key was previously Omitted?](https://stackoverflow.com/questions/58998309/how-to-merge-back-a-key-with-a-type-where-the-key-was-previously-omitted) – jcalz Dec 02 '19 at 17:01
  • I understand that keys can be added/removed, and that types can be narrowed. My question is how can I express that I expect this function to take a T that definitely has the keys of `IDocument`? What constraint can I use such that if they omit `_id` or narrow the type of `_id`, then it to no longer matches the constraint? – Chad Dec 02 '19 at 17:14
  • Maybe like [this](http://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgJIBED2CCuBbCcZAbwChkLkB9YAEwC5kR8AjaAblIF9SYcQEYYJhDJIAZzABxQtGAIAPABUAfAAoAbo2LIA2gAVkoZAGsIAT0wxkSgLoB+RkoO3kXAJQk3vfoOGiJMABhEUkoOFAwBXJKJWQIAA9IEFpxNCxcAiIAMhiKHV0AaSNRM0trDGx8QjBbRkrMmqLXROTUm2bkew7C10YQCA1oN3VabCd3Rg1MOk4+ASERMQhJEJAwiPA1MYR6jOrwTzJKZARQzAAbCAA6C8wAc23sa5pad04TwJkBqHkFBoOYHUOlejB2Lzobne3FIkWg8CQNgAFhAAGrQcwAMWAUEk+yyYHiSUI7QBBJIeWodEYAEYYbQIAgLnAoCgzutCWANDSGMi0RjsbiwPiapxAmsNpE1Fyee9kAB6eXxKBQTBQACEpFIDKZLLZoUJOz2VQJYpWwQN4SlOzliuQmBMcHMWqAA)? It's an unnatural constraint in TS. – jcalz Dec 02 '19 at 17:26
  • That looks promising. I was surprised that even if I change T to force it to have _id:number it [still failed](http://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgJIBED2CCuBbCcZAb2QH1gATALmRHwCNpkBfAKDbAE8AHFAdWBgAFqkoAeACoA+ZAF5kAeTxCpAGmQByCpU2yAZCXJVa9PEyisA3Bxg4QCMMEwhkkAM5gA4oWjAEUtIAFAButKQA2gAKyKDIANYQXJgwyJIAugD8tJLR6awAlGyk7Gx2Dk4ubhCeAMIunlBwoGBSyBAAHpAglO5oWLgE4MGU2DlFxGzI09WePiB+AYIiYoHBpDq0owgAdDqFNuxAA). – Chad Dec 02 '19 at 17:29
  • I could flesh that out into an answer if you want... the compiler really doesn't want to prevent narrowing of properties. If you don't expect people to narrow properties I'd suggest using a type assertion (e.g., `testGeneric({ _id: doc._id } as T);`) and moving on. Otherwise you end up with the complex mess where you prevent narrowing properties in a call signature and widen the implementation signature to either no longer be generic or loosen your constraints enough to make it work. – jcalz Dec 02 '19 at 17:29
  • I think explaining in a bit more detail what you've described so far with some workaround examples, and your recommendation would be a good answer. Especially for others landing here with similar questions. – Chad Dec 02 '19 at 17:35

1 Answers1

1

The original example had a value like {_id: 10} assigned to a generic type like Partial<T> where T extends IDocument. In such cases you can show that the compiler is correct to complain, since there are some types T that extend IDocument where 10 is not a valid property. This is essentially the same issue as in this question.


The new example where you assign {_id: doc._id} to the generic type Partial<T> where T extends IDocument still yields an error, even though it should definitely be safe. The compiler is unable to verify that Pick<T, "_id"> is assignable to Partial<T>. This is (related to) an open issue; the compiler currently can't do the sort of type analysis necessary to ensure this. In situations where you are sure something is safe (and you've double and triple checked) but the compiler isn't, you might want to use a type assertion:

testGeneric<T>({ _id: doc._id } as Partial<T>); // okay now

So inside the implementation of testConstraint() you may need to use a type assertion or the equivalent (such as a single call-signature overload with a looser implementation signature).


Finally, you said you wanted to actually prevent someone from calling testConstraint<T>() with a T whose properties are narrower than those on IDocument. This is more restrictive than T extends IDocument, and is more cumbersome to represent in TypeScript where property narrowing is a natural part of subtyping. You can do it by making the generic constraint include a conditional mapped type, like this:

function testConstraint<
    T extends IDocument &
    { [K in keyof IDocument]: IDocument[K] extends T[K] ? T[K] : never }>(doc: T): void;
function testConstraint(doc: IDocument) {
    testGeneric({ _id: doc._id });
}

Here we have T constrained to both IDocument and a type where each property of IDocument is compared to the corresponding property of T. If the one on T is no narrower than the one on IDocument, great. Otherwise, the property in the constraint is narrowed all the way to never, to which T likely will not match.

The call signature is complicated enough that the types inside the implementation would really confuse the compiler. That's why I made it an overload, and loosened the implementation signature to be entirely non-generic. You might be able to do something generic for the implementation, but the point is that you should probably treat the call side and the implementation separately in terms of types.

Let's see that function in action:

interface TheVeryFirstDocument extends IDocument {
    _id: 1
}
declare const tv1d: TheVeryFirstDocument;
testConstraint(tv1d); // error!
// --------->  ~~~~~
//  Types of property '_id' are incompatible.
//    Type '1' is not assignable to type 'never'.(

That gives an error, as we desire, while the following works without error:

declare const doc: IDocument;
testConstraint(doc); // okay

interface ExtendedDocument extends IDocument {
    title: string;
    numPages: number;
}
declare const xDoc: ExtendedDocument;
testConstraint(xDoc); // okay

Okay, hope that helps; good luck! Link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360