1

A bit of a followup to this question on NoUnion types -- Is there a way to prevent union types in TypeScript?

Is it possible to construct a Record type such that the value of each property can be a primitive type but not a union of primitive types?

I.e., that these object would be valid:

{ a: string }
{ b: number }
{ c: boolean }

But these would not:

{ a: string | number }
{ b: boolean | string }
sam256
  • 1,291
  • 5
  • 29
  • How is this question different from the one you linked? It sounds like both you and the other asker want to stop union types from being allowed. Does the solution posted on that question not work for you? – Silvio Mayolo Jan 12 '22 at 00:06
  • @SilvioMayolo, maybe I'm just being dense but I can't see how to apply that solution to a record. `Record>` is sort of the concept, but of course does not work. – sam256 Jan 12 '22 at 01:39
  • 1
    There is no specific type in TypeScript which works the way yo want. You can come up with something that *checks* a given type (indeed `NotAUnion` in the other question is a generic type function that checks its type parameter `T`; it is not a specific type like `NotAUnion`). Does [this approach](https://tsplay.dev/mAvo8W) meet your needs? If so, I can write up an answer explaining it. If not, please consider [edit]ing the question with a [mre] that demonstrates unsatisfied use cases. – jcalz Jan 12 '22 at 03:09
  • @jcalz, yes, this is the functionality i was trying to get but couldn't see how. if you don't mind writing up why your answer works, that would be great. i think i get the general idea--a mapped type that maps to never if the type isn't an exact match to allowed types, but there are a few tricky bits in there i wouldn't mind understanding better (e.g., why the `Omit` is necessary, and why the, apparently, double mapping). Thanks! – sam256 Jan 12 '22 at 03:42
  • There are different ways to implement this, so it's not like `Omit` or the double mapping are exactly *necessary*. When I get a chance I'll write up the answer; probably in a while since it's bedtime in jcalzland now. – jcalz Jan 12 '22 at 03:55
  • @jcalz, studying your example a little more, the two questions I have are (1) why does the `Omit` trick work, i.e., why does it generate an object from an array? and (2) What is happening in the "successful" case of the double mapping? is it basically creating a union type of, e.g. `string | never | never` (or whatever the "allowed" type is, if not string)? thanks again! – sam256 Jan 12 '22 at 13:02
  • 1
    A tuple type is an object type written in a simple format; it is essentially equivalent to an object type with known numeric-like keys `"0"`, `"1"`, etc., along with all the member keys of a general array (like `"push"`, and `number` for the numeric index signature). The `Omit` just leaves the known numeric-like keys so I don't worry about iterating over the other ones. Note that the tuple is just a simple format so you could write `[string, number, boolean]` instead of `{foo: string, bar: number, baz: boolean}` with keys nobody cares about. – jcalz Jan 12 '22 at 14:41
  • 1
    Yes, it creates a union type, where `never` gets absorbed in unions, so `string | never | never` is `string`, and `never | number | never` is `number`. I'm happy to explain in more detail when I get to writing up an answer. – jcalz Jan 12 '22 at 14:41
  • Thanks. I'm happy to accept the answer if you feel like writing it up, but these comments give me what I needed. Nice solution! – sam256 Jan 12 '22 at 15:21

2 Answers2

1

I would say that specific ask is impossible, because boolean itself I believe is not a primitive, but a union itself of type true | false.

Nathan Wiles
  • 841
  • 10
  • 30
  • Sure; that's a fair point, but as the post I linked too notes that could be worked around as a one-off case. My bringer problem is how to have the NotAUnion type apply to every property in a Record. I feel like I'm missing something obvious... – sam256 Jan 12 '22 at 01:40
1

There is no specific type in TypeScript which works the way you want. That is, you can't write anything like type RecordOfOnlyAllowedTypes = ... and check that your candidate type is assignable to it by T extends RecordOfOnlyAllowedTypes.

Instead, you can come up with a generic type type RecordOfOnlyAllowedTypes<T> that acts as a constraint on a candidate type T. If RecordOfOnlyAllowedTypes<T> is a supertype of T, then the constraint will succeed; otherwise it will fail. So you want to design RecordOfOnlyAllowedTypes<T> to pass through a valid type unchanged, but transform an invalid one into something T doesn't extend, such as a type where the offending properties are replaced with the never type. Once you do this, anything that needs to verify that a type is valid would be of the form T extends RecordOfOnlyAllowedTypes<T>, a technique known as "F-bounded quantification" that lets you represent types in terms of themselves.

(Note that this is also how you might use the NoUnion<T> type from the answer to the other question also, by the way. You check T extends NoUnion<T> instead of using some impossible-to-represent specific NoUnion type.)


There are many possible implementations of RecordOfOnlyAllowedTypes; here's one:

type AllowedTypes = [string, number, boolean]; // <-- put the types you want to allow in here
type _AT = Omit<AllowedTypes, keyof any[]>
type RecordOfOnlyAllowedTypes<T extends object> = {
  [K in keyof T]: {
    [I in keyof _AT]: (
      T[K] extends _AT[I] ? (
        _AT[I] extends T[K] ? (
          T[K]
        ) : never
      ) : never
    )
  }[keyof _AT]
};

First, I wanted it to be easy to specify the allowed types. I didn't assume that they would be "primitive" or "not unions". Instead I have an AllowedTypes type which is a tuple of the relevant types. I just made it string, number, boolean. It's sort of an off-brand use of a tuple type; I don't care about the order; I mostly just want to keep the different types separated (so string | number | boolean wouldn't work, because boolean expands to true | false) in an easy-to-look-at manner.

The next step is to turn AllowedTypes into something easier to iterate, so Omit<AllowedTypes, keyof any[]> uses the Omit<T, K> utility type to strip anything "array-like" away from AllowedTypes to produce _AT, a type with just some keys for known element positions, like {0: string, 1: number, 2: boolean}. Again, we don't care about the order, or the keys. {foo: string, bar: number, baz: boolean} would be fine.

Armed with _AT we can build the main functionality of RecordOfOnlyAllowedTypes<T>. We make a mapped type over the properties of T, checking each one against _AT. So for a key K, if the property type T[K] is one of the elements of _AT, we leave it alone. If it isn't, we map it to never.

For each key I in the keys of _AT, we evaluate T[K] extends _AT[I] ? _AT[I] extends T[K] ? T[K] : never : never. Essentially if _AT[I] and T[K] "mutually extend" each other, then we consider them equal (this isn't always true in TypeScript, but I'm not worried about any weird edge cases and you probably shouldn't either). Note that string extends string | number but the reverse isn't true. Then we form the union of those for all those keys of _AT (by indexing into the inner mapped type with keyof _AT). If T[K] is equal to at least one of the property types of _AT, then this mapped type will be T[K]; otherwise it will be never.


Okay, and finally, to help us test, we'll provide a check() function with the constraint T extends RecordOfOnlyAllowedTypes<T> and see what happens when we specify T in different ways:

function check<T extends RecordOfOnlyAllowedTypes<T>>() { };

check<{ a: string }>(); // okay
check<{ b: number }>(); // okay
check<{ c: boolean }>(); // okay
check<{ a: string | number }>(); // error
check<{ b: boolean | string }>(); // error

Looks good! This is what you wanted. The first three are fine because the properties are each one of the AllowedTypes elements, and the last two fail because they are not.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360