3

I have a requirement for an object type to not duplicate keys across nested objects. For example, if foo.bar contains the key hello then foo.baz cannot contain that key. Is there any way to enforce this at the type level?

One simplified formulation might be something like the following:

type NestedUniqueKeys<T extends Object> = any // <- what goes here?

interface Something {
  one: string
  two: string
  three: string
  four: string
}

const good: NestedUniqueKeys<Something> = {
  foo: {
    three: 'hi',
    one: 'hi',
  },
  bar: {
    two: 'hiya',
  },
}

// @ts-expect-error
const bad: NestedUniqueKeys<Something> = {
  foo: {
    two: 'hi', // duplicated
    one: 'hi',
  },
  bar: {
    two: 'hiya', // duplicated
  },
}

So a simpler step might be, how could NestedUniqueKeys be formulated for a single level of nesting?

Then, how to extend it to arbitrary nestings?

const good: NestedUniqueKeys<Something> = {
  foo: {
    three: 'hi',
    baz: {
      one: 'oh',
      bill: {
        four: 'uh',
      },
    },
  },
  bar: {
    two: 'hiya',
  },
}

// @ts-expect-error
const bad: NestedUniqueKeys<Something> = {
  foo: {
    three: 'hi',
    baz: {
      one: 'oh',
      bill: {
        four: 'uh', // duplicated
      },
    },
  },
  bar: {
    two: 'hiya',
    foobar: {
      four: 'hey', // duplicated
    },
  },
}

And in the final formulation, could it be made to infer the full set of keys so no type parameter needs to be passed in?

Edit

I tried an initial sketch of something approaching the solution, but this results in all nested keys being forbidden. I guess this is because K is inferred to be string when it's passed into the recursive NestedUniqueKeys? I'm not sure why...

type NestedUniqueKeys<Keys extends string = never> = {
  [K in string]: K extends Keys
    ? never
    : string | NestedUniqueKeys<K|Keys>
}

Playground

Edit 2

Another attempt, I'm not sure why this isn't allowing any keys in the nested objects...

type NestedUniqueKeys<Keys extends string = never> =
  { [K in string]: K extends Keys ? never : string } extends infer ThisLevel
  ? keyof ThisLevel extends string
  ? ThisLevel & {
    [N in string]: N extends Keys ? never : NestedUniqueKeys<keyof ThisLevel|Keys>
  }
  : never
  : never
Igid
  • 515
  • 4
  • 15
  • Is it only about "leaf" keys like `one` through `four` or do `foo`, `bar`, `baz` and `bill` also need to be unique? – Thomas Nov 18 '22 at 11:46
  • Good question, for my current use case I'm only interested in leaf keys but I imagine the general gist of the solution will be the same for either. – Igid Nov 18 '22 at 11:58
  • Not possible with a static type annotation. You need a function to do this kind of inference. – kelsny Nov 18 '22 at 13:52
  • @caTS something gives me the feeling you haven't quite grokked the fact that typescript is Turing complete at the type level – Igid Nov 18 '22 at 15:00
  • @captain-yossarianfromUkraine Ok, I'm willing to admit that might be the case, but I'd like to understand why. What is missing from the type system that prevents this? We have conditional branching and recursion, and the ability to build up a set of keys via a string union... – Igid Nov 18 '22 at 15:31
  • @Igid in order to do this sort of validation, you need to infer whole type and then go through each key. In other words, you need to create an utility type which expects an argument and this argument should be this object – captain-yossarian from Ukraine Nov 18 '22 at 15:52
  • @Igid You misunderstand me. I'm not talking about a runtime check. You need a function since a static type annotation like this (where it's just an assignment) is unable to "operate" on the value being assigned. A function is given "access" to the value directly and so you can do more things with it, so to speak... – kelsny Nov 18 '22 at 15:53
  • @caTS My understanding is that generic types are functions at the type level. ie. `MyType` is a function from A and B to a resulting type. – Igid Nov 18 '22 at 15:54
  • @captain-yossarianfromUkraine right, that does sound a bit more compelling... But I'm kind of at the stage with this where I'm not asking if it's a good idea, I'm asking if it's possible. And it still feels possible... if a very bad idea. – Igid Nov 18 '22 at 15:55
  • If there are infinite possibilities for this structure (since you don't restrict the names of the nested keys, only the leaf keys), then you can't represent it as a single type. You need to instead check if the given type matches your expectations. That is why a static type annotation won't work, and that is why you need a function for inferences. – kelsny Nov 18 '22 at 15:58
  • @caTS Right, I get you. That maybe rules out my final question, about inferring the set of keys. But possibly leaves the first two suggestions as possible, where we pass in the set of allowable keys, and use the type to restrict the distribution of keys from the passed in type? – Igid Nov 18 '22 at 16:02
  • Nope, since (maybe I put it in a confusing way) like I said, you don't restrict the parent keys. You have "foo" and "bar" in your example and those could be any other keys as well. – kelsny Nov 18 '22 at 16:05

3 Answers3

2

Please consider this example:


type Primitives = string | number | symbol;

type Values<T> = T[keyof T]

// https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
    k: infer I
) => void
    ? I
    : never;

// https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union#answer-53955431
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

/**
 * Compute all possible property combinations
 */
type KeysUnion<T, Cache extends PropertyKey = never> =
    /**
     * If T extends string | number | symbol -> return Cache, this is the end/last call
     */
    T extends PropertyKey
    ? Cache
    : {
        /**
         * Otherwise, iterate through keys of T, because T is an object
         * and unionize Cache 
         */
        [P in keyof T]: KeysUnion<T[P], Cache | P>

    }[keyof T]

type Validate<
    /**
     * Our main data structure
     */
    Obj,
    /**
     * Expected to be  key from the union of all keys
     */
    Key extends PropertyKey,
    /**
     * Result
     */
    Cache extends Record<string, any> = never,
    /**
     * Index is provided to  distinguish same keys on different nesting levels. Because if you unionize same keys, you will end up with one element in the union
     */
    Index extends number[] = [],
    /**
     * Root is provided to make a difference between keys on the same nesting level, because diff leafs might have same keys on same levels
     */
    Root extends string = ''

> =
    /**
     * If provided Obj is a primitive, it is the end of recursion
     */
    Obj extends Primitives
    /**
     * Our result
     */
    ? Exclude<Cache, []>
    : {
        /**
         * Iterate through object keys
         */
        [Prop in keyof Obj]:
        /**
         * Check whether object keys extends argument Key, it will be first encounter of a key
         * Hence, if it will be a second one encounter, we will add to our cache next key: Root-Prop-index
         * Son if Cache contains a union it means that we have more than one match of a key
         */
        Prop extends Key
        ? Validate<Obj[Prop], Key, Record<Key, `${Root}-${Prop & string}-${Index['length']}`>, [...Index, Index['length']], Root extends '' ? Prop : Root>
        : Validate<Obj[Prop], Key, Cache, [...Index, Index['length']], Root extends '' ? Prop : Root>
    }[keyof Obj]

type Structure = {
    foo: {
        three: 'hi',
        bar: {
            one: 'oh',
            bill: {
                four: 'uh', // duplicated
            },
        },
    },
    bar: {
        two: 'hiya',
        foobar: {
            four: 'hey', // duplicated
        },
    },
}



type Distribute<Data, Keys extends PropertyKey = KeysUnion<Data>> =
    Keys extends any ? IsUnion<Validate<Data, Keys>> extends true ? Record<Keys, 'many'> : Record<Keys, 'one'> : never

type Result = keyof UnionToIntersection<Exclude<Distribute<Structure>, Record<string, 'one'>>>

Playground

You can find explanation in comments and in my blog

  • 1
    That's quite impressive. I was stuck trying to find where to start. How would you use this in a function, though? I suppose it can tell you the keys that were duplicated, but ideally it should error on the key where it was used. – kelsny Nov 18 '22 at 16:36
  • @caTS to be honest, spend some time on this )) `KeysUnion` returns all keys. Then I again iterate through the object and distributively check each key. If I found a key, I am adding key/pair to accumulator . To distinguish it, I use this pattern ``${Root}-${Prop & string}-${Index['length']}``, to avoud union clashing if we have same key on a same nesting level – captain-yossarian from Ukraine Nov 18 '22 at 16:41
  • 1
    @caTS if you wish, you can write a validation function and provide your extended answer. Anyway, I am going to drink wine with my wife and friends :) – captain-yossarian from Ukraine Nov 18 '22 at 16:43
2

I have found the following solution for the simple case:

type NestedUniqueKeys<T extends object> = keyof T[keyof T] extends never ? T : never

It is actually pretty neat and small. The more general solution is a tad more complicated:

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
// extracts all keys of the object in the form "A.B.C.D", D limits depth
type AllKeys<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    UnionToString<{ [K in keyof T]-?: K extends string | number ?
        `${K}` | AllKeys<T[K], Prev[D]>
        : never
    }[keyof T]> : never

// convert a union to an intersection: X | Y | Z ==> X & Y & Z
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// convert a union to an overloaded function X | Y ==> ((x: X)=>void) & ((y:Y)=>void)     
type UnionToOvlds<U> = UnionToIntersection<U extends any ? (f: U) => void : never>;

// returns true if the type is a union otherwise false
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;

// takes last from union
type PopUnion<U> = UnionToOvlds<U> extends ((a: infer A) => void) ? A : never;

// converts "A" | "B" | "C" ==> "C.B.A"
type UnionToString<U> = IsUnion<U> extends false ? (U extends string ? U : never)
: (PopUnion<U> extends infer P extends string ? `${P}.${UnionToString<Exclude<U, P>>}`: "")

// Checks if "A.B.B.C" has any duplicates between the "."
type Unique<T> = T extends `${infer First}.${infer Rest}` ? Contains<First, Rest> extends true ? false : Unique<Rest> : true

// Checks if "A" is contained in "A.B.C"
type Contains<T, STR> = T extends STR ? true : STR extends `${infer First}.${infer Rest}` ? T extends First ? true : Contains<T, Rest> : false

type NestedUniqueKeys<T extends object> = Unique<AllKeys<T>>

I got some of the helper types from various sources. This does not really seem useful to me, but it was a fun challenge.

Garuno
  • 1,935
  • 1
  • 9
  • 20
2

Here is another approach. I tried to keep it simple.


I created three generic types to do the validation.

type GetLeafPaths<T, K extends keyof T = keyof T> = 
  K extends K
    ? T[K] extends object
      ? `${GetLeafPaths<T[K]> & string}${K & string}.`
      : `${K & string}.`
    : never

GetLeafsPaths takes an object T and computes all the paths to the leafs as strings. The result would look like this for the first object:

// "three.foo." | "one.baz.foo." | "four.bill.baz.foo." | "two.bar." | "four.foobar.bar."

Note that I chose to have the path in reverse order. This makes it easier to get the leaf value later as is it just the first element.

ExtractLeafName takes a path and extracts the LeafName.

type ExtractLeafName<Path extends string> =  
  Path extends `${infer LeafName}.${string}`
    ? LeafName
    : never

type Result1 = ExtractLeafName<"four.bill.baz.foo.">
//   ^? type Result1 = "four"

Now to the main Validation type.

type Validate<T, Paths = GetLeafPaths<T>> = 
    {
      [K in Paths & string]: 
        ExtractLeafName<K> extends ExtractLeafName<Exclude<Paths, K> & string> 
          ? true 
          : false
    } extends Record<string, false>
      ? true
      : false

The idea is simple: First get all the Paths with GetLeafPaths. Then we map over each path P in Paths.

For each path P, we use Exclude<Paths, P> to get all the other paths in Paths which are not P. We use ExtractLeafName to get the leaf names from both P and Exclude<Paths, P> and compare them with extends. If a leaf name is in any other path, we return true and return false if not.

This produces an object type:

{
    "three.foo.": false;
    "one.baz.foo.": false;
    "four.bill.baz.foo.": true;
    "two.bar.": false;
    "four.foobar.bar.": true;
}

Duplicate leaf names have a true type.

All that is left to do is to check if there are any true values in this object type which we can check with extends Record<string, false>.

The Validate type returns false if any leaf name is duplicated.


Now we only need a function to which we can pass a real object.

function nestedUniqueKeys<T>(arg: Validate<T> extends true ? T : never) {
    return arg
}

A simple conditional type in the parameter let's us use Validate to check T for duplicate leafs.

// Error: is not assignable to parameter of type 'never'
nestedUniqueKeys({
    foo: {
        three: 'hi',
        baz: {
            one: 'oh',
            bill: {
                four: 'uh', // duplicated
            },
        },
    },
    bar: {
        two: 'hiya',
        foobar: {
            four: 'hey', // duplicated
        },
    },
})

// OK
nestedUniqueKeys({
  foo: {
    three: 'hi',
    baz: {
      one: 'oh',
      bill: {
        four: 'uh',
      },
    },
  },
  bar: {
    two: 'hiya',
  },
  topLevelLeaf: "asdasd"
})

The error message is not really helpful. But this is as good as I can get it.


Side note: This can also trivially be expanded to not only validate unique leafs, but all properties. All you need to do is change GetLeafPaths to also construct the paths of the other properties:

type GetLeafPaths<T, K extends keyof T = keyof T> = 
  K extends K
    ? T[K] extends object
      ? `${K & string}.` | `${GetLeafPaths<T[K]> & string}${K & string}.`
      : `${K & string}.`
    : never

Playground

Tobias S.
  • 21,159
  • 4
  • 27
  • 45
  • 1
    Nice approach, more readable than mine :)) good work – captain-yossarian from Ukraine Nov 19 '22 at 10:28
  • Very nice, I can follow this pretty well! I will try it out later, but a couple of wee questions: What role does the `K extends K` play in the first helper type? And why can't we turn the validation type into a type directly assignable to an instance of the object (ie. doing away with the function) by swapping out the final `? true : false` for `? T : never`? – Igid Nov 20 '22 at 15:41
  • It feels a bit dirty to me to use a string as the data structure to extract property paths. Couldn't array literals serve the same purpose? – Igid Nov 20 '22 at 15:42
  • `K` is `keyof T` which is a union of keys. We want to process each union member individually by distributing over them. `K extends K` enables distribution. – Tobias S. Nov 20 '22 at 22:52
  • You can return `T` from `Validation`. But really whats the point? You still have to pass the *whole* object structure as `T` to the generic type. In the end you would have to spell out the whole object twice. Once as a type to pass it to `Validation` and again as an object literal to assign it to the result of `Validation`. – Tobias S. Nov 20 '22 at 22:54
  • Yes, array literals could have been used too. It would probably be better. I can imagine some edge cases where property names could have a `.` in their name which could break the splitting of the string. – Tobias S. Nov 20 '22 at 22:54