IsNegativeNumber<N> returns true for negative literals, false for zero and positives, and never for number or unions. Union detection is the tricky part.
Checking the minus sign is easy. Recognizing that the input is a union is the actual puzzle.
To rule out (or enforce) that a numeric literal is positive, you first need a way to tell whether it's negative. IsNegativeNumber is a type-level function that accepts a number N and returns:
true if N is negativefalse if N is positivefalse if N is 0never if N is numbernever if N is a unionThe sign check itself is one line. The interesting parts are detecting the wide number type and detecting union inputs, two guard patterns you'll reuse whenever a type utility only makes sense for a single concrete literal.
Sometimes when working with numeric literals, we need to rule out (or enforce) that the provided number is a positive integer.
To do that, we first need a way to tell if the number is negative.
Write a type-level function IsNegativeNumber that accepts a number N and returns:
true if N is negativefalse if N is positivefalse if N is 0,never if N is numbernever if N is a unionView on GitHub: https://tsch.js.org/25747
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 stacks three guards in front of a one-line check:
type IsUnion<T, U = T> = T extends U ? ([U] extends [T] ? false : true) : never
type IsNegativeNumber<T extends number> = number extends T
? never
: IsUnion<T> extends true
? never
: `${T}` extends `-${string}`
? true
: falseReject number, reject unions, then read the sign. In that order.
number type?The constraint T extends number already guarantees every input is assignable to number. The reverse question, number extends T, is only true when T is number itself: no literal like -1 or 0 can absorb the entire number type. So this line singles out exactly the IsNegativeNumber<number> case and returns never, as the challenge demands. Without this guard, IsNegativeNumber<number> would fall through to the sign check, and since `${number}` doesn't match `-${string}`, it would wrongly return false instead of never. A wide number has no knowable sign, so we bail out before ever trying to inspect one.
IsUnion is a classic idiom built from two mechanics:
T is a naked type parameter in T extends U ? ... : ..., TypeScript splits a union apart and evaluates the conditional once per member. The default parameter U = T quietly keeps an unsplit copy of the original type for each of those evaluations.[U] extends [T], compares the types as-is, without splitting.Walk through IsUnion<-1 | -2>. Distribution runs the body twice: once with T = -1 and once with T = -2, but U stays -1 | -2 both times. Is [-1 | -2] assignable to [-1]? No, the union is wider than the member, so each pass yields true:
IsUnion<-1 | -2> // true | true β true
IsUnion<-1> // [-1] extends [-1] β falseFor a single literal, the member and the whole are identical, so the tuple check succeeds and the result is false. In short: a type is a union exactly when it's strictly wider than each of its own members. When IsUnion<T> is true, we return never. (The trailing : never inside IsUnion itself is unreachable, since every distributed member extends the union it came from; it's just the mandatory false branch of the conditional.)
You might wonder why IsNegativeNumber doesn't just distribute over the union and return true for each member. That's exactly the behavior the challenge rules out: IsNegativeNumber<-1 | -2> must be never, not true. So we detect the union explicitly instead of letting distribution happen.
With wide types and unions out of the way, T is a single numeric literal. Stringify it with a template literal and pattern-match on the first character:
[object Object]`${-1.9}` evaluates to the string literal '-1.9', which matches `-${string}` β true. `${0}` is '0' and `${1.9}` is '1.9', no leading minus β false. There's no type-level arithmetic or comparison happening anywhere; the minus sign in the literal's text is all we need.
IsNegativeNumber<0> β false: zero stringifies to '0', no minus sign, so it falls out of the same check as positives. No special case needed.IsNegativeNumber<-1.9> β true: floats stringify with their sign intact, so decimals need no extra handling.IsNegativeNumber<-100_000_000> β true: numeric separators are purely source-code syntax; the type is just -100000000 and stringifies without underscores.IsNegativeNumber<number> and IsNegativeNumber<-1 | -2> β never: caught by guards 1 and 2 before the sign check ever runs.Both guard patterns, number extends T for wideness and the tuple-wrapped IsUnion, show up constantly in production type utilities. That's what earns this small challenge its "hard" label.
This challenge is originally from here.