Issue
I have this function
function something<T extends string>(): void {
// ...
}
I would like to restrict T
to be a SINGLE string, and not a union of strings:
something<'stringOne'>(); // I want this to be allowed
something<'stringOne' | 'stringTwo'>(); // I DON'T want this to be allowed
Is there a way to do that?
Solution
Here's one way to approach it:
type NotAUnion<T> = [T] extends [infer U] ? _NotAUnion<U, U> : never;
type _NotAUnion<T, U> = U extends any ? [T] extends [U] ? unknown : never : never;
function something<T extends string & NotAUnion<T>>(): void {
// ...
}
something<'stringOne'>(); // okay
something<'stringOne' | 'stringTwo'>(); // error
You can verify that it works.
The idea of NotAUnion<T>
is that if T
is a union type, NotAUnion<T>
should resolve to the never
type (TypeScript's bottom type; nothing other than never
itself is assignable to never
); otherwise, if T
is not a union type, NotAunion<T>
should resolve to the unknown
type (TypeScript's top type; every type is assignable to unknown
).
The constraint T extends string & NotAUnion<T>
is a recursive constraint, also known as F-bounded quantification, which is a tricky thing to get the compiler to see as acceptable. If you're not careful you can get warnings about circular type definitions. I would have worked around this in another way, but your example doesn't have any other place where T
is used (usually it would be doSomething<T>(t: T): void
where T
appears as the type of a parameter or something), and so I'm keeping a version where T extends XXX
is the only way you can express the constraint.
Anyway, T extends string & NotAUnion<T>
expresses the idea that T
must be assignable to string
and must not be a union: If T
is a union, then string & NotAUnion<T>
will resolve to string & never
, which resolves to never
, and thus T extends never
will be violated. On the other hand, if T
is not a union, then string & NotAUnion<T>
will resolve to string & unknown
, which resolves to string
, and thus T extends string
will be satisfied if T
is assignable to string
.
This gives you your desired behavior for "stringOne"
versus "stringOne" | "stringTwo"
. It will also accept string
itself, which is not a union. If you want to prohibit this you can do so with a more complex constraint, but I'm considering that out of scope for the question as asked (since it wasn't one of the specified use cases).
So the only thing left to explain here is how NotAUnion<T>
works. The general idea is that NotAUnion<T>
will check to see if T
is assignable to all the union members of T
individually. If so, then T
is not a union (since it has just one member). If not, then T
is a union.
The approach here is first to use conditional type inference with infer
to copy T
into another type parameter U
. By wrapping this in a single-element tuple, we are preventing this from being a distributive conditional type. So in [T] extends [infer U] ? ...T,U... : never
, both T
and U
will be the same type.
And so [T] extends [infer U] ? _NotAUnion<U, U> : never
is essentially the same as just _NotAUnion<T, T>
, except that the former would make the compiler complain about circularity. So T extends _NotAUnion<T, T>
is seen as illegally circular, but T extends NotAUnion<T>
is not. Copying from T
to U
and then checking U
seems to be the important piece here.
Anyway, then the _NotAUnion<T, U>
type intentionally distributes the type function across union members in U
, by writing a distributive conditional type U extends any ? ..,T,U... : never
. Inside that type function, T
will be the original T
type, while U
will be each individual union member of T
. Thus is T
is "stringOne" | "stringTwo"
, then U
will be "stringOne"
and "stringTwo"
in turn.
And finally, the real comparison: [T] extends [U] ? unknown : never
. This is another non-distributive type (we don't want to break T
up into pieces). If T
is not a union, then this will end up being something like "stringOne" extends "stringOne"
which will result in unknown
. But if T
is a union, then this will become ("stringOne" | "stringTwo") extends "stringOne"
which is false, so you get never
. It also checks ("stringOne" | "stringTwo") extends "stringTwo"
, which is equally as false.
And there you go. It's a bit convoluted, obviously. There are other ways to write a function like this, but they are equally or more weird. A slightly less complex one-liner version of NotAUnion<T>
is mentioned in microsoft/TypeScript#34504 but it seems to rely on unspecified behavior that changes depending on the version of TypeScript. Instead of hoping that the one-liner version always behaves like the two-line version I show here, I just use them more reliable two-line version here.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.