#553Hard

Deep object to unique

Brand an object and every nested object with its own identity while staying mutually assignable with the original. Nominal typing built by hand.

Nominal typing built by hand: every node of an object tree gets its own identity, and assignability with the original survives.

TypeScript has a structural type system, but sometimes you want a function to accept only certain previously defined objects, not any object that happens to have the required fields. DeepObjectToUniq<O> makes an object and all its deeply nested objects unique while preserving every key and value. The catch: the original type and the unique type must be mutually assignable yet not identical, and two nested objects with the same shape must end up different from each other. The branding technique you'll use here is the same one behind real-world nominal typing libraries.

For example,

import { Equal } from "@type-challenges/utils"
 
type Quz = { quz: 4 }
type Foo = { foo: 2; baz: Quz; bar: Quz }
 
type UniqFoo = DeepObjectToUniq<Foo>
 
declare let foo: Foo
declare let uniqFoo: UniqFoo
 
uniqFoo = foo // ok
foo = uniqFoo // ok
 
type T0 = Equal<UniqFoo, Foo> // false
type T1 = UniqFoo["foo"] // 2
type T2 = Equal<UniqFoo["bar"], UniqFoo["baz"]> // false
type T3 = UniqFoo["bar"]["quz"] // 4
type T4 = Equal<keyof Foo & string, keyof UniqFoo & string> // true

Challenge Instructions: Deep object to unique

Hard

TypeScript has structural type system, but sometimes you want a function to accept only some previously well-defined unique objects (as in the nominal type system), and not any objects that have the required fields.

Create a type that takes an object and makes it and all deeply nested objects in it unique, while preserving the string and numeric keys of all objects, and the values of all properties on these keys.

The original type and the resulting unique type must be mutually assignable, but not identical.

For example,

import { Equal } from "@type-challenges/utils"
 
type Foo = { foo: 2; bar: { 0: 1 }; baz: { 0: 1 } }
 
type UniqFoo = DeepObjectToUniq<Foo>
 
declare let foo: Foo
declare let uniqFoo: UniqFoo
 
uniqFoo = foo // ok
foo = uniqFoo // ok
 
type T0 = Equal<UniqFoo, Foo> // false
type T1 = UniqFoo["foo"] // 2
type T2 = Equal<UniqFoo["bar"], UniqFoo["baz"]> // false
type T3 = UniqFoo["bar"][0] // 1
type T4 = Equal<keyof Foo & string, keyof UniqFoo & string> // true

View on GitHub: https://tsch.js.org/553

Change the following code to make the test cases pass (no type check errors).

👋 Lifetime-License is leaving on August 10, 2026

Get it now for $29

One-time payment. Lifetime access to all pro challenges.

Loading...

Detailed Explanation

All of it fits in one declaration and one type:

declare const KEY: unique symbol
 
type DeepObjectToUniq<
  O extends object,
  Root = O,
  Path extends PropertyKey[] = [],
> = {
  [K in keyof O]: O[K] extends object
    ? DeepObjectToUniq<O[K], Root, [...Path, K]>
    : O[K]
} & { readonly [KEY]?: [Root, Path] }

A phantom brand property makes the type distinguishable from the original. Its optional modifier keeps assignability intact, and a path accumulator threaded through the recursion makes every nesting level unique. One piece at a time.

Branding with a unique symbol

declare const KEY: unique symbol declares a symbol whose type is unlike any other; even another unique symbol is a different type. The declare const is load-bearing: a computed property key must be a value, not a type, so we need a constant whose type is a unique symbol, and declare makes it exist at the type level only, emitting no runtime code. Intersecting the object with { readonly [KEY]?: [Root, Path] } attaches a phantom property keyed by that symbol. This is the classic branding trick: the property never exists at runtime, but at the type level it makes UniqFoo distinguishable from Foo, so Equal<UniqFoo, Foo> is false.

Using a symbol key rather than a string one is what keeps the last example true: keyof UniqFoo is keyof Foo | typeof KEY, and intersecting with string filters the symbol out, so keyof Foo & string and keyof UniqFoo & string agree. The string and numeric keys are preserved untouched.

Why the brand must be optional

The challenge demands mutual assignability: uniqFoo = foo and foo = uniqFoo must both compile.

The readonly modifier is a small courtesy: nobody should ever write to a property that doesn't exist at runtime.

Making nested objects unique with a path accumulator

Branding only the top level isn't enough. Here are the test file's definitions in full, the same Quz and Foo as in the intro plus a second root type Bar:

type Quz = { quz: 4 }
type Foo = { foo: 2; baz: Quz; bar: Quz }
type Bar = { foo: 2; baz: Quz; bar: Quz & { quzz?: 0 } }

In Foo, bar and baz are the same type, yet Equal<UniqFoo['bar'], UniqFoo['baz']> must be false. If the recursion were plain DeepObjectToUniq<O[K]>, both would evaluate to DeepObjectToUniq<Quz>, which is identical. The fix is to thread two extra parameters through the recursion:

Each nested object's brand is then [Root, Path], a fingerprint of where it lives:

// UniqFoo['bar'] is branded with [Foo, ['bar']]
// UniqFoo['baz'] is branded with [Foo, ['baz']]  → different from 'bar'
// UniqQuz        is branded with [Quz, []]       → different from both

Including Root matters too. Foo and Bar in the tests both have a baz: Quz property, so their paths match, but their roots differ. That is why Equal<UniqBar['baz'], UniqFoo['baz']> is false.

The mapped recursion

The main body is a homomorphic mapped type: [K in keyof O] copies every property, and the conditional O[K] extends object ? ... : O[K] decides whether to recurse. Primitives like foo: 2 pass through untouched, which is why UniqFoo['foo'] is still exactly 2 and UniqFoo['bar']['quz'] is still 4. Only object-valued properties get wrapped in another branded layer.

Edge cases the tests cover

Branding via an optional unique symbol property is a technique you'll meet in production code, for opaque IDs and validated strings. This challenge shows how far it can be pushed: full nominal identity for every node of an object tree.

This challenge is originally from here.

Share this challenge

Learn the Concepts