#37189•Hard

Compare

Return 1, -1 or 0 for two numeric literal types. The digits only reach you as a string, so signs, decimal points and scientific notation are yours to handle.

TypeScript knows that 100 and 200 are different types. It has no opinion on which one is larger.

Compare<T, U> supplies that opinion: 1 when T is greater, -1 when U is greater, 0 when they are equal, and the full union -1 | 0 | 1 when either side is the wide number type and the answer is genuinely unknown. There is no arithmetic at the type level, so the only handle you get on a numeric literal is its string form. Everything below is string work: strip the sign, undo scientific notation, then compare digits the way you would on paper.

type C1 = Compare<100, 200> // expected to be -1
type C2 = Compare<100, -100.1> // expected to be 1
type C3 = Compare<number, 100> // expected to be -1 | 0 | 1

Challenge Instructions: Compare

Hard

Compare takes two number types T and U. If T is greater than U, return 1; if U is greater than T, return -1. If they are equal, return 0. If T or U is type number, return -1 | 0 | 1.

View on GitHub: https://tsch.js.org/37189

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

type Compare<T extends number, U extends number> = any

/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'

type Test01 = 100
type Test02 = 200
type Test03 = 100.1
type Test04 = 200.1
type Test05 = -100
type Test06 = -200
type Test07 = -100.1
type Test08 = -200.1
type Test09 = 1e25
type Test10 = 1e28
type Test11 = 1.5e25
type Test12 = 1.5e28
type Test13 = -1e25
type Test14 = -1e28
type Test15 = -1.5e25
type Test16 = -1.5e28
type Test17 = 1e-8
type Test18 = 1e-10
type Test19 = 1.5e-8
type Test20 = 1.5e-1

Pro Challenge

Get access to all 200+ challenges, including every medium, hard, and extreme one.

One-time payment, lifetime access. 30-day money-back guarantee.

Detailed Explanation

The solution in full:

type CompareDigit<A extends string, B extends string, D extends string = '0123456789'> =
  A extends B
    ? 0
    : D extends `${infer H}${infer T}`
      ? H extends A ? -1 : H extends B ? 1 : CompareDigit<A, B, T>
      : 0
 
type AllZeros<S extends string> =
  S extends '' ? true : S extends `0${infer R}` ? AllZeros<R> : false
 
type CompareAligned<A extends string, B extends string> =
  A extends `${infer AH}${infer AR}`
    ? B extends `${infer BH}${infer BR}`
      ? CompareDigit<AH, BH> extends 0
        ? CompareAligned<AR, BR>
        : CompareDigit<AH, BH>
      : AllZeros<A> extends true ? 0 : 1
    : AllZeros<B> extends true ? 0 : -1
 
type CompareLength<A extends string, B extends string> =
  A extends `${string}${infer AR}`
    ? B extends `${string}${infer BR}` ? CompareLength<AR, BR> : 1
    : B extends '' ? 0 : -1
 
type CompareInt<A extends string, B extends string> =
  CompareLength<A, B> extends 0 ? CompareAligned<A, B> : CompareLength<A, B>
 
type Tuple<N extends number, Acc extends unknown[] = []> =
  Acc['length'] extends N ? Acc : Tuple<N, [...Acc, unknown]>
 
type Split<S extends string> =
  S extends `${infer I}.${infer F}` ? [I, F] : [S, '']
 
type MoveRight<I extends string, F extends string, N extends unknown[]> =
  N extends [unknown, ...infer R]
    ? F extends `${infer H}${infer FR}`
      ? MoveRight<`${I}${H}`, FR, R>
      : MoveRight<`${I}0`, '', R>
    : [I, F]
 
type MoveLeft<I extends string, F extends string, N extends unknown[]> =
  N extends [unknown, ...infer R] ? MoveLeft<'0', `${I}${F}`, R> : [I, F]
 
type Expand<S extends string> =
  S extends `${infer M}e${infer E}`
    ? E extends `-${infer N extends number}`
      ? MoveLeft<Split<M>[0], Split<M>[1], Tuple<N>>
      : E extends `+${infer N extends number}`
        ? MoveRight<Split<M>[0], Split<M>[1], Tuple<N>>
        : never
    : Split<S>
 
type CompareMagnitude<A extends string, B extends string> =
  CompareInt<Expand<A>[0], Expand<B>[0]> extends 0
    ? CompareAligned<Expand<A>[1], Expand<B>[1]>
    : CompareInt<Expand<A>[0], Expand<B>[0]>
 
type Negate<C> = C extends -1 ? 1 : C extends 1 ? -1 : 0
 
type CompareSigned<A extends string, B extends string> =
  A extends `-${infer AM}`
    ? B extends `-${infer BM}` ? Negate<CompareMagnitude<AM, BM>> : -1
    : B extends `-${string}` ? 1 : CompareMagnitude<A, B>
 
type Compare<T extends number, U extends number> =
  number extends T | U ? -1 | 0 | 1 : CompareSigned<`${T}`, `${U}`>

Numbers arrive as strings, and not always the shape you expect

`${T}` turns a numeric literal type into a string literal type, and TypeScript follows the JavaScript rules exactly:

type A = `${100.1}` // '100.1'
type B = `${1e20}` // '100000000000000000000'
type C = `${1e25}` // '1e+25'
type D = `${-1.5e-10}` // '-1.5e-10'

Magnitudes from 1e-7 up to just under 1e21 print as plain decimals; outside that window you get exponential form, always with exactly one digit before the point. So a number string is an optional -, then digits with an optional ., then an optional e+N or e-N.

Comparing digit strings

With no arithmetic available, digit order has to be spelled out. CompareDigit walks the string '0123456789' from the front and reports whichever of its two arguments it meets first as the smaller one. CompareDigit<'3', '7'> peels '0', '1', '2', then matches '3' against A and returns -1. Equal digits short-circuit on the first line.

Integer parts compare by length before anything else, because a number string never carries a leading zero: '1000' beats '999' without a single digit being inspected. CompareLength peels one character off each side per step, and whichever side empties first is the shorter one. Its base case tests B extends '' rather than pattern matching again, and that detail matters, because `${string}${string}` collapses to plain string and the empty string satisfies it too.

Once the lengths agree, CompareAligned walks both strings left to right and returns the first verdict that is not 0:

// CompareAligned<'15000', '14999'>
// '1' vs '1' -> 0, keep going
// '5' vs '4' -> 1, done

The same type serves the fraction parts, where lengths can differ. A fraction is already left-aligned, so '5' means .5, and a side that runs out of digits is equal only if the other side has nothing but zeros left. That is the one thing AllZeros is there for.

Undoing scientific notation

Expand splits a magnitude on e, so '1.5e+28' yields M = '1.5' and E = '+28'. The exponent is still a string, and infer N extends number is what turns '28' into the number literal 28. Tuple<N> then builds a tuple of that length:

[object Object]

Nothing ever reads the elements. The tuple exists so a recursion can count down by destructuring one element per step, which is how you repeat something N times in the type system.

MoveRight pulls one fraction digit into the integer part per step and appends a '0' once the fraction runs dry. MoveLeft is the mirror image and gets to be shorter, because in exponential form the integer part is a single digit: each step pushes it onto the front of the fraction and resets the integer part to '0'.

// Expand<'1.5e-10'>
// start   I = '1'  F = '5'     10 steps left
// step 1  I = '0'  F = '15'     9 steps left
// step 2  I = '0'  F = '015'    8 steps left
// result  ['0', '00000000015']

Plain decimals skip all of that and go straight to Split, which cuts on the .. After Expand, every number has the same [integerDigits, fractionDigits] shape, which is the whole point: 100 and 1e25 become comparable.

Signs last

CompareSigned peels a leading - from either side. If only one side has it, that side loses and no digits are touched. If both have it, the magnitudes are compared and Negate flips the verdict, since among negatives the larger magnitude is the smaller number. The number check sits above all of it: number extends T | U holds only when one of the two is the wide type, because number does not extend a union of literals.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges