If you want Unique<T, V>
to extend T
if and only if at most one property of T
is assignable to V
, then I think the following formulation does that with a minimum of weird edge cases (although weird edge cases can be very weird indeed, so you should test any edge cases you care about):
type ProhibitProperty<T, V> = unknown extends {
[K in keyof T]: T[K] extends V ? unknown : never
}[keyof T] ? never : unknown;
type Unique<T extends object, V> = { [K in keyof T]:
T[K] & (T[K] extends V ? ProhibitProperty<Omit<T, K>, V> : unknown)
}
declare function f<T extends object>(t: Unique<T, string>): void;
The idea: Unique<T, V>
will ensure that if any property at key K
is assignable to V
, no property of T
other than the one with key K
is also assignable to V
. It does this by inspecting Omit<T, K> which is like T
but with the K
-keyed property removed. It intersects the property type T[K]
with ProhibitProperty<Omit<T, K>, V>
.
For ProhibitProperty<T, V>
, if any of the properties of T
are assignable to V
, the output type will be the never
type, TypeScript's bottom type which absorbs all intersections (i.e., A & never
reduces to never
for all A
). On the other hand, if none of the properties of T
are assignable to V
, the output type will be the unknown
type, TypeScript's top type which is absorbed into all intersections (i.e., A & unknown
is A
for all A
) .
So when you intersect T[K]
with ProhibitProperty<Omit<T, K>, V>
, you will either get T[K]
, if no other property of T
is assignable to V
, or never
, if any other property of T
is assignable to V
. Oh, and if T[K]
is not assignable to V
we just intersect it with unknown
, since we essentially leave all non-V
properties alone.
Let's make sure it works:
f({ a: '', b: 0, c: 1, d: 2 }); // okay
f({ a: '', b: 0, c: '' }); // error!
// ~ <------> ~
// string is not assignable to never
Looks good. The compiler accepts the object literal with one string
value, and rejects the one with multiple string
values. In the error case, it complains about both string
properties, which is about the best we can do, since there's no principled way in TypeScript to choose a member of a union (although terrible hacky methods exist, see How to transform union type to tuple type for how to do this and why you shouldn't).
Playground link to code