48

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?

Jacob Gillespie
  • 3,981
  • 3
  • 23
  • 33
  • TypeScript isn't geared for that. I have a way to force the compiler (using conditional types) to constrain a function parameter to a type matching your intended `ComboObject` type (exactly one extra key with string property and no other properties) but it is horrendous and not something you'd want to use in any production code. If you're interested I can post it but I think you might want to pursue other more TypeScript-friendly options instead. – jcalz Apr 23 '18 at 02:37
  • @jcalz yeah, if you could post or otherwise send it that would be great, it might spark some ideas even if it's not totally workable. – Jacob Gillespie Apr 23 '18 at 03:36
  • 1
    This is a really specific case where you want exactly 3 properties, 2 known and 1 unknown. . I had a situation where I had one known property and then any number of unknown (string s). I did like this one: `props: { children: string[] | VDOMElement[] } & { [key: string]: string };` However, putting them together without the `&` operator **didn't** work: `props: { children: string[] | VDOMElement[], [key: string]: string };` – CodeFinity Mar 11 '22 at 13:43
  • 1
    @CodeFinity even with the `& {[key: string]: string}` approach that often doesn't work, since the known properties cannot be unioned with a string: https://www.typescriptlang.org/play?#code/CYUwxgNghgTiAEkoGdnwGoBEDyBZAohCALYgB2ALvAN4C+AUPRQJ4AOC+AHlMa0fAF4aiABYBLCMDhkAXPGQUYYsgHMA2gF14AHww4CRUpU3xa8AGTC1AaxDM5Cpao0PFylacagkceADMAVzIwCjEAezJ4EG5eIgAKADcoCDkuHj4QAEo5BLCxYEZo9PjqMHFJaTk1AHIREAgIMOqNWkz4AHp2qJgYMJh4ACNwKADkBAADMokpcnHEKDIyMKoh+Ch5N1U1smBttZ6oZiA – Jacob Gillespie Mar 11 '22 at 14:01
  • Well, no red lines on my end in my actual code...yet. And, no I am not 'cheating' by dumbing down `tsconfig`. :) It's pretty strict. – CodeFinity Mar 12 '22 at 17:19

4 Answers4

32

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!

Community
  • 1
  • 1
jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is awesome, thank you! It helps with understanding how the type system works for sure. One final question, is it possible for the unknown key to have an interface type rather than a string type? I would have assumed that would be possible by changing `Record` to `Record`, but that seems to allow any arbitrary properties in the interface. – Jacob Gillespie Apr 23 '18 at 19:52
  • 1
    What do you mean by "any arbitrary properties"? It won't allow you to leave properties out or give them the wrong types, will it? If you mean excess properties, yeah, that's not surprising. Excess property checking only happens in particular places in TypeScript. It doesn't have true [exact types](https://github.com/Microsoft/TypeScript/issues/12936), and forcing the compiler to reject excess properties would require another round of [type golf](https://github.com/Microsoft/TypeScript/issues/12936#issuecomment-368244671). – jcalz Apr 24 '18 at 00:26
  • Interesting, you are correct, it is allowing additional properties but is correctly type checking the given properties. Thanks for the link to GitHub, that also explains how you'd get exact-like behavior. – Jacob Gillespie Apr 24 '18 at 15:06
  • This example fails as of TypeScript 3.5.1 with error "Type 'keyof C' is not assignable to type 'string'" – Matthew Dean Sep 18 '19 at 13:43
  • 10
    @jcalz Is there no easier way to do this in TypeScript as of the latest version? I really just want something like: `{foo: number; [key: notFoo]: string}` – Matthew Dean Sep 18 '19 at 14:21
  • 2
    If you want `notFoo` to be a *single* string key, the above is probably still how I'd do it. If you just want `notFoo` to be any key or set of keys that isn't `foo`, then you still need to use a generic mapped conditional type but it's a bit simpler. Until and unless TypeScript implements [arbitrary index signature types](https://github.com/Microsoft/TypeScript/pull/26797) and [negated types](https://github.com/microsoft/TypeScript/pull/29317), there's no concrete way to say `{foo: number; [k: string & not "foo"]: string}`. – jcalz Sep 18 '19 at 14:40
6

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

hugo00
  • 117
  • 1
  • 9
6

Seems to me the much simpler way to do this is with an intersection type:

type ComboObject = {
  known: boolean
  field: number
} & {
  [key: string]: string | number | boolean;
};

This tells TypeScript to inherit from the left, but doesn't get mad when you offer it the additional unknown param types.

A slightly more verbose and complex solution, but can offer some additional constraints by explicitly Excludeing the known types:

type ComboObject = {
  known: boolean;
  field: number;
} & Record<Exclude<string, "known" | "field">, string | number | boolean>;
const combine: ComboObject = {
  known: true,
  field: 1,
  name: "name",
  age: 1,
  isAlive: true,
};

combone?.known // boolean
combone?.field // number
combone?.name // string | number | boolean
combone?.age // string | number | boolean
combone?.isAlive // string | number | boolean
brandonscript
  • 68,675
  • 32
  • 163
  • 220
  • 1
    This only works if the type for the unknown keys (`string | number | boolean` in this example) is the union of all possible know key value types. You could think about the goal of the question as being the inverse, i.e. "what if all unknown keys are strings, but these specific known keys are a boolean and a number". – Jacob Gillespie Dec 15 '22 at 12:34
  • @JacobGillespie I agree with the conclusion in your comment but I also want to keep complexity waaaaaaay down so I'd rather have it reasonably correct and also be able to explain it to my colleague in a minute. Typescript could improve this. Until then, I'll opt for this answer. – ThaJay Mar 02 '23 at 11:00
1

OK this was a big issue for me but I finally found a good and simple solution!

export type Overwrite<Base, Overrides> = Omit<Base, keyof Overrides> & Overrides;

export type LocationsType = Overwrite<
  {
    [key: string]: LocationType
  },
  {
    add: (location: LocationData) => void
    addMultiple: (locationsData?: LocationsData) => void
    getAllSources: (maxAmount?: number) => SourceType[]
  }
>

So what this means is that all keys are strings with value LocationType apart from the keys in overrides, those specific string keys have the specified value.

No more { [key: string]: LocationType | () => void | (someParam:string) => void | any }


For completeness, it was just a small change because I already used Overwrite for this pattern:

const customerKeys = ['id', 'firstName', 'lastName', 'birthDate'] as const
type CustomerKey = typeof customerKeys[number]
export type CustomerData = Record<CustomerKey, string>
export type CustomerType = Overwrite<
  CustomerData,
  {
    id: number
    birthDate: Date
    locations: LocationsType
  }
>

This types a JSON response and the parsed usable result as data and type.

ThaJay
  • 1,758
  • 1
  • 19
  • 30