The only possible way this could work at compile time is if your arrays are tuples composed of literals. For example, here are some arrays with the same runtime values, but with different types in TypeScript:
const tupleOfLiterals: [1, 2, 2] = [1, 2, 2];
const tupleOfNonLiterals: [number, number, number] = [1, 2, 2];
const arrayOfLiterals: (1 | 2)[] = [1, 2, 2];
const arrayOfNonLiterals: number[] = [1, 2, 2];
const constAssertedReadOnlyTupleOfLiterals = [1, 2, 2] as const;
Only the first one would behave as you’d like… the compiler would realize that tupleOfLiterals
has exactly 3 elements, two of which are the same type. In all the other cases, the compiler doesn’t understand what’s going on. So if you’re passing arrays you get from other functions, or from an API, etc., and the type of these arrays is something like number[]
, then the answer is just “no, you can’t do this”.
If you’re getting tuples of literals (possibly via const
assertion)… say, from a developer using your code as a library, then you have a chance of getting something that works, but it’s complex and possibly brittle. Here’s how I might do it:
First we come up something that acts like an invalid type, which TypeScript doesn’t have. The idea is a type to which no value can be assigned (like never
) but which produces a custom error message when the compiler encounters it. The following isn’t perfect, but it produces error messages that are possibly reasonable if you squint:
type Invalid<T> = Error & { __errorMessage: T };
Now we represent UniqueArray
. It can’t be done as a concrete type (so no const a: UniqueArray = ...
) but we can represent it as a generic constraint that we pass to a helper function. Anyway, here’s AsUniqueArray<A>
which takes a candidate array type A
and returns A
if it is unique, and otherwise returns a different array where there are error messages in the places that are repeated:
type AsUniqueArray<
A extends ReadonlyArray<any>,
B extends ReadonlyArray<any>
> = {
[I in keyof A]: unknown extends {
[J in keyof B]: J extends I ? never : B[J] extends A[I] ? unknown : never
}[number]
? Invalid<[A[I], "is repeated"]>
: A[I]
};
That uses a lot of mapped and conditional types, but it essentially walks through the array and looks to see if any other elements of the array match the current one. If so, there’s an error message.
Now for the helper function. Another wrinkle is that by default, a function like doSomething([1,2,3])
will treat [1,2,3]
as a number[]
and not a [1,2,3]
tuple of literals. There’s no simple way to deal with this, so we have to use weird magic (see the link for discussion of that magic):
type Narrowable =
| string
| number
| boolean
| object
| null
| undefined
| symbol;
const asUniqueArray = <
N extends Narrowable,
A extends [] | ReadonlyArray<N> & AsUniqueArray<A, A>
>(
a: A
) => a;
Now, asUniqueArray()
just returns its input at runtime, but at compile time it will only accept array types it perceives as unique, and it will put errors on the problem elements if there are repeats:
const okay = asUniqueArray([1, 2, 3]); // okay
const notOkay = asUniqueArray([1, 2, 2]); // error!
// ~ ~
// number is not assignable to Invalid<[2, "is repeated"]> | undefined
Hooray, that is what you wanted, right? The caveats from the beginning still hold, so if you end up getting arrays that are already widened (either non-tuples or non-literals), you’ll have undesirable behavior:
const generalArray: number[] = [1, 2, 2, 1, 2, 1, 2];
const doesntCareAboutGeneralArrays = asUniqueArray(generalArray); // no error
const arrayOfWideTypes: [number, number] = [1, 2];
const cannotSeeThatNumbersAreDifferent = asUniqueArray(arrayOfWideTypes); // error,
// Invalid<[number, "is repeated"]>
Anyway, all this might not be worth it for you, but I wanted to show that there is kind of, sort of, maybe, a way to get close to this with the type system. Hope that helps; good luck!
Playground link to code