2

I was wondering if it is possible in typescript to type the max number of dynamic properties in an object.

So the basic example is for tracking events:

events.track('SOME_EVENT', { first: 'a', other: 'b', some: 'c'})

the event data is supposed to hold a maximum of 3 properties with their respective values, the keys could also be dynamic.

I've typed it with a basic Record, but there is no limit on the amount of properties allowed:

export interface Events {
  track: (name: string, params?: Record<string, string | number | unknown>) => void;
}

Is this possible?

Tobias S.
  • 21,159
  • 4
  • 27
  • 45
Jose Paredes
  • 3,882
  • 3
  • 26
  • 50

2 Answers2

6

I got a solution using TuplifyUnion from here:

I am not sure how "safe" this is (see the disclaimer). Using TuplifyUnion is considered unsafe since the order may change at any time. Since the order is not important here and only the amount of elements matters in this case, I think this is ok to use here.

The solution allows 0-3 keys. If you want any other amounts, just add them into the union (e.g. 1 | 2 accepts 1 or 2 keys).

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type LastOf<T> =
  UnionToIntersection<T extends any ? () => T : never> extends () => (infer R) ? R : never

type Push<T extends any[], V> = [...T, V];

type TuplifyUnion<T, L = LastOf<T>, N = [T] extends [never] ? true : false> =
  true extends N ? [] : Push<TuplifyUnion<Exclude<T, L>>, L>


type MaxThreeProperties<T extends Record<string, any>> =
  TuplifyUnion<keyof T>["length"] extends 0 | 1 | 2 | 3 ? T : never
//    add all acceptable key amounts here ^   ^   ^   ^

function track<T extends Record<string, any>>(
  name: string, 
  params: MaxThreeProperties<T>
) {}

We basically put all keys into a tuple and then "manually" check the length of the tuple. This can be easily extended to other amounts of properties though it might get ugly.

One downside is the error message though:

Type 'string' is not assignable to type 'never'.(2322)

This may be confusing for someone using the function...


Here are some tests:

track('SOME_EVENT', {})                                  // works
track('SOME_EVENT', {a: ""})                             // works
track('SOME_EVENT', {a: "", b: ""})                      // works
track('SOME_EVENT', {a: "", b: "", c: ""})               // works
track('SOME_EVENT', {a: "", b: "", c: "", d: ""})        // ERROR
track('SOME_EVENT', {a: "", b: "", c: "", d: "", e: ""}) // ERROR

const a = {a: "", b: "", c: ""}
const b = {a: "", b: "", c: "", d: ""}

track('SOME_EVENT', a)  // works
track('SOME_EVENT', b)  // ERROR

Playground

Tobias S.
  • 21,159
  • 4
  • 27
  • 45
  • Even though this seems to work, the boilerplate for the type is a lot, even more, if I have to increase the limit in the future !! thanks a lot – Jose Paredes May 10 '22 at 11:06
  • You can reduce the long chain of extends clauses to just `TuplifyUnion["length"] extends 0 | 1 | 2 | 3` :) –  May 11 '22 at 13:50
  • @halfdecent wow that is way shorter :D did not know that this was a thing – Tobias S. May 11 '22 at 15:09
3

I can't think of a way to declare an object interface with 1-3 (but only 1-3) unknown string-named properties (but that doesn't mean there isn't one; I'm only at a journeyman level with TypeScript).

I'd lean toward a union of tuples:

type EventParam = [name: string, value: string | number | unknown];
type EventParams = 
      [EventParam]
    | [EventParam, EventParam]
    | [EventParam, EventParam, EventParam];
export interface Events {
    track: (name: string, params?: EventParams) => void;
}

declare let events: Events;
// Works with 1:
events.track("something", [["first", "a"]]);
// Works with 2:
events.track("something", [["first", "a"], ["other", "b"]]);
// Works with 3:
events.track("something", [["first", "a"], ["other", "b"], ["some", "c"]]);
// Fails with 4:
events.track("something", [["first", "a"], ["other", "b"], ["some", "c"], ["fourth", 42]]);
// Error as desired −−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−^^^^^^^^^^^^^^

Playground link

But that may not have the best ergonomics.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • Hey ! Thanks for the reply, even though this seems like a good alternative to the interface, It looks like we are hiding the object with the array of arrays. – Jose Paredes May 10 '22 at 11:00