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