Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I type an object with known and unknown keys in TypeScript

I am looking for a way to create TypeScript types for the following object that has two known keys and one unknown key that has a known type:

interface ComboObject {   known: boolean   field: number   [U: string]: string }  const comboObject: ComboObject = {   known: true   field: 123   unknownName: 'value' } 

That code does not work because TypeScript requires that all properties match the type of the given index signature. However, I am not looking to use index signatures, I want to type a single field where I know its type but I do not know its name.

The only solution I have so far is to use index signatures and set up a union type of all possible types:

interface ComboObject {   [U: string]: boolean | number | string } 

But that has many drawbacks including allowing incorrect types on the known fields as well as allowing an arbitrary number of unknown keys.

Is there a better approach? Could something with TypeScript 2.8 conditional types help?

like image 337
Jacob Gillespie Avatar asked Apr 22 '18 18:04

Jacob Gillespie


People also ask

How do you give an object a key to type?

Use the keyof typeof syntax to create a type from an object's keys, e.g. type Keys = keyof typeof person . The keyof typeof syntax returns a type that represents all of the object's keys as strings. Copied!

How do you write an object in TypeScript?

Syntax. var object_name = { key1: “value1”, //scalar value key2: “value”, key3: function() { //functions }, key4:[“content1”, “content2”] //collection }; As shown above, an object can contain scalar values, functions and structures like arrays and tuples.


2 Answers

You asked for it.

Let's do some type manipulation to detect if a given type is a union or not. The way it works is to use the distributive property of conditional types to spread out a union to constituents, and then notice that each constituent is narrower than the union. If that isn't true, it's because the union has only one constituent (so it isn't a union):

type IsAUnion<T, Y = true, N = false, U = T> = U extends any   ? ([T] extends [U] ? N : Y)   : never; 

Then use it to detect if a given string type is a single string literal (so: not string, not never, and not a union):

type IsASingleStringLiteral<   T extends string,   Y = true,   N = false > = string extends T ? N : [T] extends [never] ? N : IsAUnion<T, N, Y>; 

Now we can start taking about your particular issue. Define BaseObject as the part of ComboObject that you can define straightforwardly:

type BaseObject = { known: boolean, field: number }; 

And preparing for error messages, let's define a ProperComboObject so that when you mess up, the error gives some hint about what you were supposed to do:

interface ProperComboObject extends BaseObject {   '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!': string } 

Here comes the main course. VerifyComboObject<C> takes a type C and returns it untouched if it conforms to your desired ComboObject type; otherwise it returns ProperComboObject (which it also won't conform to) for errors.

type VerifyComboObject<   C,   X extends string = Extract<Exclude<keyof C, keyof BaseObject>, string> > = C extends BaseObject & Record<X, string>   ? IsASingleStringLiteral<X, C, ProperComboObject>   : ProperComboObject; 

It works by dissecting C into BaseObject and the remaining keys X. If C doesn't match BaseObject & Record<X, string>, then you've failed, since that means it's either not a BaseObject, or it is one with extra non-string properties. Then, it makes sure that there is exactly one remaining key, by checking X with IsASingleStringLiteral<X>.

Now we make a helper function which requires that the input parameter match VerifyComboObject<C>, and returns the input unchanged. It lets you catch mistakes early if you just want an object of the right type. Or you can use the signature to help make your own functions require the right type:

const asComboObject = <C>(x: C & VerifyComboObject<C>): C => x; 

Let's test it out:

const okayComboObject = asComboObject({   known: true,   field: 123,   unknownName: 'value' }); // okay  const wrongExtraKey = asComboObject({   known: true,   field: 123,   unknownName: 3 }); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing  const missingExtraKey = asComboObject({   known: true,   field: 123 }); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing  const tooManyExtraKeys = asComboObject({   known: true,   field: 123,   unknownName: 'value',   anAdditionalName: 'value' }); // error, '!!!ExactlyOneOtherStringPropertyNoMoreNoLess!!!' is missing 

The first one compiles, as desired. The last three fail for different reasons having to do with the number and type of extra properties. The error message is a little cryptic, but it's the best I can do.

You can see the code in action in the Playground.


Again, I don't think I recommend that for production code. I love playing with the type system, but this one feels particularly complicated and fragile, and I wouldn't want to feel responsible for any unforeseen consequences.

Hope it helps you. Good luck!

like image 176
jcalz Avatar answered Sep 17 '22 21:09

jcalz


Nice one @jcalz

It gave me some good insight to get where I wanted. I have like a BaseObject with some known properties and the BaseObject can have as many BaseObjects as it wants.

type BaseObject = { known: boolean, field: number }; type CoolType<C, X extends string | number | symbol = Exclude<keyof C, keyof BaseObject>> = BaseObject & Record<X, BaseObject>; const asComboObject = <C>(x: C & CoolType<C>): C => x;  const tooManyExtraKeys = asComboObject({      known: true,      field: 123,      unknownName: {          known: false,          field: 333      },      anAdditionalName: {          known: true,          field: 444      }, }); 

and this way I can get type checks for the structure that I already had without changing too much.

ty

like image 39
hugo00 Avatar answered Sep 21 '22 21:09

hugo00