1

I want to guarantee that there is not more than one field in T of type P. I am thinking something like:

declare function f<T, U extends Unique<T, string>>(t: T);

so that

f({a : '', b: 0, c: 0})

compiles but

f({a : '', b: 0, c: 0, d: ''})

does not.

I was thinking something from

type R = (keyof { a : string } ) ['length']

But R here is number not literally 1.

Edit: I need to be able to specify the type P, such that there is a maximum of one field of that type or subtype.

Michael Lorton
  • 43,060
  • 26
  • 103
  • 144
  • Does [this approach](https://tsplay.dev/NBjeVN) meet your needs? There are many potential edge and corner cases (e.g., what if one property isn't the same type as another but is a subtype or supertype? ) so I'm hoping you can present any use cases you really care about before anyone goes through the trouble of writing up an answer. – jcalz Jan 25 '22 at 20:45
  • 1
    @jcalz Neat solution. I think you can replace `Omit` with just `T` though, since you're excluding K right after – Jason Jan 25 '22 at 21:02
  • @jcalz — in my case, I have close control over the set of types being used, so the problems you mention don’t arise. However, I do need to *specify* the unique type; other types can be used more than once. I will update the question. – Michael Lorton Jan 25 '22 at 21:04
  • Ohh okay... let me look at that – jcalz Jan 25 '22 at 21:05
  • @jcalz [I got it](https://www.typescriptlang.org/play?#code/C4TwDgpgBAqgdgSwI4FcIB4AqUIA9gRwAmAzlAPYBGAVhAMbAA0UACgHxQC8UA3lANoBpKAjhQA1hBDkAZlEwBdAFxR5QhTnyFSrKAH4oAUVx0ANiiIZM65teNmLGSdLmZmgtgo4rrghQF8AKEDLMwBDACdoGRQ4BgRyMRksTQJiMipaBjYACmAVeGQ0LGYSYAjRAHM2AEoVADdyBCIAbmDAmRy+MJUAcl7mShUABih-GrbO7r6BqCGoYeY6GbGJjq6oHqh+wZGlkdWWoA) but since you did the hard work, feel free to submit it as the answer so I can give you proper credit. – Michael Lorton Jan 25 '22 at 21:13
  • Does [this approach](https://tsplay.dev/wX72LW) work then? Go through whatever use cases you care about and let me know. – jcalz Jan 25 '22 at 21:13
  • I'm a bit worried about weird edge cases like `f({a: Math.random()<0.5 ? "": 0, b: 1, c: ""})`. I guess we don't have to worry about those? – jcalz Jan 25 '22 at 21:14
  • @jcalz — Well in your example, `number | string` is not a subtype of string, so it should be legal. In my case I am modeling a database table, which has at most one primary key — so the object is a `Record` and one of them is a `PrimaryField`, which is a subtype of `Field`, so all is good. – Michael Lorton Jan 25 '22 at 21:26
  • I wrote an answer; do note that TS can easily forget properties, like [this](https://tsplay.dev/w164XN), since that is part of how structural typing works (`{a: string, b: string} extends {a: string}`) so it's always possible to pass in something with more properties than you expect. You should consider `Unique` a "best-effort" sort of thing, and hopefully harden things at runtime if it really matters – jcalz Jan 25 '22 at 22:29

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360