Write uniqueItems, a function that rejects tuples with duplicate elements and pins the compiler error on each repeated element instead of the whole argument.
Make the compiler your linter: write a function that accepts [1, 2, 3] but red-squiggles the second 2 in [1, 2, 2], on exactly that element.
The tool for this is a constrained identity function (CIF): a function that returns its argument unchanged but uses its type signature to enforce a rule that no plain type annotation could express. Here the rule is that all elements of a tuple are unique. Some concepts in TypeScript can't be described by types, but can be expressed through type constraints. For example, you can't define a type for positive numbers, but you can check whether a number literal type is positive:
// Ensures `n` is a positive number
function positive<const N extends number>(n: `${N}` extends `-${string}` ? never : N) {
return n
}
const a = positive(1) // Ok
const b = positive(-1) // Error, -1 is not assignable to neverYour task is a CIF uniqueItems that takes a tuple of literals and ensures all of them are unique. There are two bonus goals: helpful error messages instead of not assignable to never, and errors that land on the individual repeated elements rather than the whole argument. Along the way you'll use const type parameters, tuple recursion with a "seen" accumulator, and self-referential parameter types.
uniqueItems([1, 2, 3]) // OK, returns readonly [1, 2, 3]
uniqueItems([
1,
2,
2, // Error here: Type '2' is not assignable to type '2 & DuplicateItemError<2>'
3,
])Some concepts in TypeScript can not be described by types, but can be expressed through type constraints. For example, you can't define a type for positive numbers, but you can check whether a number literal type is positive. One of the patterns for applying such constraints is constrained identity function (CIF). A CIF takes one parameter, infers its type, performs additional checks and returns the parameter unmodified.
// Ensures `n` is a positive number
function positive<const N extends number>(n: `${N}` extends `-${string}` ? never : N) {
return n
}
const a = positive(1) // Ok
const b = positive(-1) // Error, -1 is not assignable to neverWrite a CIF uniqueItems that takes a tuple of literals and ensures that all of them are unique.
You are free to use either mutable or readonly tuples.
Bonus task: Helpful error messages instead of not assignable to never.
Bonus task: Only repeating tuple elements should be treated as errors, not the entire argument.
View on GitHub: https://tsch.js.org/30178
Change the following code to make the test cases pass (no type check errors).
👋 Lifetime-License is leaving on August 10, 2026
Get it now for $29
One-time payment. Lifetime access to all pro challenges.
The solution in full:
interface DuplicateItemError<Item> {
error: 'Duplicate item'
item: Item
}
type Includes<Haystack extends readonly unknown[], Needle> =
Haystack extends readonly [infer Head, ...infer Rest]
? Equal<Head, Needle> extends true
? true
: Includes<Rest, Needle>
: false
type UniqueItems<
Items extends readonly unknown[],
Seen extends readonly unknown[] = [],
> = Items extends readonly [infer Head, ...infer Rest]
? readonly [
Includes<Seen, Head> extends true ? DuplicateItemError<Head> : Head,
...UniqueItems<Rest, [...Seen, Head]>,
]
: readonly []
function uniqueItems<const Items extends readonly unknown[]>(
items: Items & UniqueItems<Items>,
): Items {
return items
}The core idea: infer the exact tuple the caller passed, rebuild a "corrected" version of it where every duplicate element is replaced by an error type, and demand that the argument match that corrected version. Where the tuples agree, nothing happens. Where they disagree, the compiler complains on that exact element.
const ItemsWithout help, TypeScript widens [1, 2, 2] to number[], and once the literals are gone there's nothing left to compare. The const modifier on the type parameter (TypeScript 5.0+) tells the compiler to infer the argument as if it had as const: uniqueItems([1, 2, 2]) infers Items as readonly [1, 2, 2], an exact tuple of literal types. That's the raw material every later step depends on.
Includes: has this element appeared before?Includes<Haystack, Needle> is a type-level Array.prototype.includes. It walks the tuple one head at a time and compares each element to the needle with Equal, the strict comparison type that ships with the challenge's helpers, rather than with extends.
What is
Equal? It's defined astype Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false. It embedsXandYin two otherwise identical generic function signatures and asks whether those signatures are assignable. The compiler only answerstruewhen the two types are identical, not merely mutually assignable. That's what makes it stricter than anyextendscheck.
That distinction matters with mixed literals: Equal<false, boolean> is false, while an extends check would happily say false is a boolean and report phantom duplicates. A quick intermediate example:
type A = Includes<[1, 'a'], 'a'> // true
type B = Includes<[1, 'a'], true> // falseUniqueItems: rebuilding the tuple with errors baked inThis is the heart of the solution. It recurses through Items while carrying a Seen accumulator of everything already visited, and emits one output element per input element:
Head has not been seen: emit Head unchanged.DuplicateItemError<Head> in its place.For Items = [1, 2, 2] the result evaluates to:
[object Object]Two details are worth pausing on. First, only the second and later occurrences get replaced. The first 2 was legitimately new when it appeared, which is exactly what the bonus task asks for. Second, DuplicateItemError is a plain interface with an error message and the offending item. It exists to be incompatible with the real element and to show up verbatim in the compiler output: instead of the cryptic not assignable to never you read Type '2' is not assignable to type '2 & DuplicateItemError<2>', and the error text tells you what went wrong.
Items & UniqueItems<Items>This is the "self-referential parameter type" promised in the intro: the parameter's type is written in terms of Items, the very type parameter being inferred from that same parameter. Why not just declare items: UniqueItems<Items>? Because then Items would appear only inside a conditional type, and TypeScript would have no position to infer it from. Inference needs a "naked" Items somewhere in the parameter, and the intersection gives it one: the compiler infers Items from the left half, then checks the argument against both halves.
UniqueItems<Items> rebuilds the tuple identically, so the intersection is just Items and everything passes.[1, 2, 2], the third element must satisfy 2 & DuplicateItemError<2>, which is impossible, so it errors.Crucially, when TypeScript checks an array literal against a tuple type it elaborates mismatches element by element, attaching each error to the element expression itself. That's what makes the second bonus work: in a multi-line call, the squiggle appears on the line of the repeated element, not on the whole argument.
[undefined, null, 3, false] is accepted: Equal keeps undefined, null and false distinct from each other, where sloppier comparisons blur them.[null, undefined, null] and ['test', undefined, 'test'] are rejected: duplicates are caught even when separated by other elements, because Seen remembers the whole history, not just the previous item.Items, so callers get back the exact readonly tuple they passed in. The function validates without changing anything, which is the entire point of a CIF.CIFs are a pattern worth keeping in your toolbox: any rule you can check about a literal type, you can enforce at a call site, without a line of runtime code.
This challenge is originally from here.