SILENT
SILENT

Reputation: 4258

Typescript: Generic to specific type

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

Upvotes: 2

Views: 7179

Answers (3)

basarat
basarat

Reputation: 276239

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
  }
});

Upvotes: 0

Aron
Aron

Reputation: 9238

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.

Upvotes: 3

Mate Solymosi
Mate Solymosi

Reputation: 5967

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.

Upvotes: 1

Related Questions