2

I have a Generic type

type GenericType = {
  [key: string]: {
    prop1: string,
    prop2?: string,
    prop3?: number,
  },
};

I use the Generic type to help build / typecheck a new object I created.

const Obj1: GenericType = {
  key1: {
    prop1: "hi",
  },
  key2: {
    prop1: "bye",
    prop2: "sup",
  },
};

It works. However, when I use the new object, vscode / typescript doesn't show the keys or props of Obj1 without removing GenericType from it. I can also "extend" the type but that's code duplication.

Obj1.???

Is there way to keep the GenericType while having access to the more specific props from the new object I created from it?


Update 1

I expect vscode / typescript to show / validate

Obj1.key1.prop1
Obj1.key2.prop1

and error if

Obj1.key1.prop2
Obj1.key2.prop3
Obj1.key2.prop321
SILENT
  • 3,916
  • 3
  • 38
  • 57
  • Is your goal to restrict the keys of `Obj1` to just `key1` and `key2` or to have `key1` and `key2` offered in the autocomplete menu but still be able to add more keys later? – Mate Solymosi Aug 31 '20 at 21:38
  • @MátéSolymosi My goal is to have keyx offered. I will use it to create multiple different types of models. Then use those objects in other functions / props. – SILENT Aug 31 '20 at 21:48

3 Answers3

3

I see the use case for this being that you want GenericTypes to have any kind of string as a key but still want to determine exactly what those keys' values can be at the point where you declare them.

In that case you can make use of the Record type to restrict the allowed keys of Obj1 to only the keys you specify.

type GenericType<K extends string> = Record<K, {
  prop1: string,
  prop2?: string,
  prop3?: number,
}>

Then when defining Obj1 you can specify what the allowed keys should be by setting the union of allowed keys as the first type parameter.

const Obj1: GenericType<"key1" | "key2"> = {
  key1: {
    prop1: "hi",
  },
  key2: {
    prop1: "bye",
    prop2: "sup",
  },
};

TypeScript will now let you access both key1 and key2 with full type safety.

Obj1.key1
// (property) key1: {
//     prop1: string;
//     prop2?: string | undefined;
//     prop3?: number | undefined;
// }

EDIT

Based on OP's comment it sounds like he would rather not specify all the key names or have to check for the presence of optional fields manually.

The best way I can think of doing this while still ensuring that the object you declare matches the constraints of the GenericType interface is to do something like the following.

First you need this utility type:

type Constraint<T> = T extends Record<string, {
  prop1: string,
  prop2?: string,
  prop3?: number,
}> ? T : never

This will return never if T doesn't match the constraint or just T otherwise.

Now you declare the plain object you actually want. No type annotations.

const CorrectObj = {
  key1: {
    prop1: "hi",
  },
  key2: {
    prop1: "bye",
    prop2: "sup",
  },
};

Then you assign this object literal to another variable, but declaring that the new variable has to be of type Constraint<typeof CorrectObj>

const CheckedObj: Constraint<typeof CorrectObj> = CorrectObj

If CorrectObj matches the constraint then CheckedObj will be a simple copy of CorrectObj with all fields available.

If the object literal doesn't match the constraints however you will get a type error when trying to assign CheckedBadObj to the literal:

const BadObj = {
  key1: {
    progfdgp1: "hi",
  },
  key2: {
    prop1: "bye",
    prdfgop2: "sup",
  },
};

const CheckedBadObj: Constraint<typeof BadObj> = BadObj
//    ^^^^^^^^^^^^^
// Type '{ key1: { progfdgp1: string; }; key2: { prop1: string; prdfgop2: string; }; }' is not assignable to type 'never'. (2322)

The explanation being that Constraint<T> when T doesn't match is never, but you are still trying to assign a non-never value to CheckedBadObj, causing a type conflict!

This involves a bit of duplication in declaring two instances of every object literal but is the only way of

  1. having TypeScript know exactly which fields exist on the object, including all sub-objects, while still
  2. checking that the values of your "generic" types match your constraints

You can play around with this approach in the playground.

Aron
  • 8,696
  • 6
  • 33
  • 59
  • This is the closest to pure typescript but it has a couple of issues. Other than having to type in every key, it also offers me prop2 on key1. – SILENT Aug 31 '20 at 22:02
  • This solution has a similar issue compared to the other solution. It allows properties outside of whats limited by Generic type to be included ie. `{ key1: { prop1: "hi", prop321: "what" }` . However, its better than the other solution since no extra function wrapper is needed. – SILENT Aug 31 '20 at 22:56
  • Why is having extra properties a problem? – Aron Sep 01 '20 at 07:21
  • My purpose for using Typescript is reduce errors like type check errors or invalid properties. If I am able to create invalid properties (probably due to miss spelling) and receive no warning, I'll stick with plain JS. – SILENT Sep 01 '20 at 12:22
1

Assuming that your goal is to have key1 and key2 show up in the autocomplete menu for Obj1 but still have the ability to set additional keys later, here is a possible solution:

type GenericType = {
  [key: string]: {
    prop1: string,
    prop2?: string,
    prop3?: number,
  };
};

const generify = <T extends GenericType>(obj: T): T & GenericType => obj;

const Obj1 = generify({
  key1: {
    prop1: "hi",
  },
  key2: {
    prop1: "bye",
    prop2: "sup",
  },
});

I cannot think of a simpler solution right now that would give Obj1 the same intersection type between GenericType and the specific type containing only the key1 and key2 properties.

Mate Solymosi
  • 5,699
  • 23
  • 30
  • This is the closest to what I'm looking for, however, this is adding an unnecessary javascript function wrapper. I'm surprised there isn't a pure typescript way to resolve this. – SILENT Aug 31 '20 at 21:59
  • It also looks like I can add new props other than whats listed in the GenericType. ie `key1: { prop321: "boom" }` – SILENT Aug 31 '20 at 22:03
  • 1
    That's an unfortunate side effect of this solution since `key1: { prop1: string; prop321: string; }` technically extends `key1: { prop1: string; prop2?: string; prop3?: number; }`. – Mate Solymosi Aug 31 '20 at 22:19
  • I have tried to come up with an elegant solution to this but every approach was a bit hack-y in some way. – Mate Solymosi Aug 31 '20 at 22:43
0

Use a generic utility function (enforce) that

  • ensures the object matches GenericType using a generic constraint (extends)
  • and returns the type of the passed in object for type inference.

Code:

type GenericType = {
  [key: string]: {
    prop1: string,
    prop2?: string,
    prop3?: number,
  };
};

const enforce = <T extends GenericType>(obj: T): T => obj;

Working solution:

type GenericType = {
  [key: string]: {
    prop1: string,
    prop2?: string,
    prop3?: number,
  };
};

const enforce = <T extends GenericType>(obj: T): T => obj;

const Obj1 = enforce({
  key1: {
    prop1: "hi",
  },
  key2: {
    prop1: "bye",
    prop2: "sup",
  },
});


Obj1.key1.prop1; // Ok
Obj1.key2.prop1; // Ok

/** 
 * ERROR: does not match passed in object
 */
Obj1.key1.prop2 // Error 
Obj1.key2.prop3 // Error
Obj1.key2.prop321 // Error

Obj1.key3; // Error 

/**
 * ERRORS: Does not match GenericType
 */
const Obj2 = enforce({
  key1: { // Error 
  }
});
const Obj3 = enforce({
  key1: {
    prop1: 123, // Error
  }
});
basarat
  • 261,912
  • 58
  • 460
  • 511
  • Thanks for the response. Unfortunately, this answer is just like Mate's answer and with the same downsides. It still allows the user to enter more properties than specified in `GenericType` and when transcribed to javascript, it adds an extra function. – SILENT Sep 07 '20 at 01:00