Home > Software design >  How to pass a constrained string to a type using as parameters a parameter using that same constrain
How to pass a constrained string to a type using as parameters a parameter using that same constrain

Time:01-31

I can build number like that:

type Digits = '0'| '1'| '2'| '3'| '4'| '5'| '6'| '7'| '8'| '9'
type NonZero = Exclude <Digits, '0'>
type Negative = '-'

type PositiveNumbers =
  | NonZero
  | `${NonZero}${Digits}`
  | `${NonZero}${Digits}${Digits}`
  | `${NonZero}${Digits}${Digits}${Digits}`
  | `${NonZero}${Digits}${Digits}${Digits}${Digits}`
  | `${NonZero}${Digits}${Digits}${Digits}${Digits}${Digits}`  // Error: Expression produces a union type that is too complex to represent.(2590)

type Numbers =
  | PositiveNumbers
  | `${Negative}${PositiveNumbers}`

But then i'm limited to numbers from -99999 and 99999, and beside that using the type Numbers in other types make the compiler struggle. So i'm using a type to represent numbers as a constraint like so:

type ConstrainNumber <N extends string> =
  N extends '0'
  ? unknown
  : N extends `${infer Char}${infer Rest}`
    ? Char extends '0'
      ? never
      : Char extends Negative
        ? ConstrainNumber <Rest>
        : ConstrainNumberRec <N, Exclude <Digits, '0'>>
    : never

type ConstrainNumberRec <S extends string, D = Digits> =
  S extends `${infer Char}${infer Rest}`
  ? Char extends D ? ConstrainNumberRec <Rest> : never
  : unknown

When using it on a type it works as expected:

type UseNumbers <N extends string & _N, _N = ConstrainNumber <N>> = N

type testValid = UseNumbers <'2875467'>
type testInvalid = UseNumbers <'02875467'> // Expected error: Type 'string' does not satisfy the constraint 'never'.(2344)

But when i'm using a type that uses UseNumbers it breaks

type UseUseNumbers <N extends string & _N, _N = ConstrainNumber <N>> = 
  UseNumbers <N> // Unwanted Error: type 'N' does not satisfy the constraint 'string & ConstrainNumber<N>'

How can i pass a constraint string to a type using as parameters a parameter using that same constraint ?
And further more, i'm not sure to guess why it breaks ? On UseNumbers <N> N is already constraint or no ?

playground

CodePudding user response:

The main issue is that generic parameter defaults are not generic constraints:

type Foo<T extends number> = T;
type BadFoo = Foo<string>; // error, not satisfying constraint

type Bar<T = number> = T;
type DefaultBar = Bar; // number
type StillGoodBar = Bar<string>; // string

In the above, the type parameter T in Foo is constrained to number, while in Bar it is unconstrained but just defaults to number. So you can't write Foo<string>. If you just write Bar it will be Bar<number>, but nothing stops someone from writing Bar<string>. It's not an error, and the compiler cannot assume inside Bar that T will be a subtype of number.


In this definition,

type UseUseNumbers<N extends string & _N, _N = ConstrainNumber<N>> = ...

The _N type parameter is unconstrained. It can be anything at all, even though it defaults to ConstrainNumber<N>. Only N is constrained to string & _N.

Your primary use case here is that nobody specifies _N, and that UseUseNumbers<N> is equivalent to UseUseNumbers<N, ConstrainNumber<N>>, which compiles if and only if N extends string & ConstrainNumber<N>. That's a neat way to avoid the circularity constraint error that would happen if you wrote type Oopsie<N extends string & ConstrainNumber<N>>, but that trick only works by leaving open the possibility that _N does not depend on N.

And so the compiler cannot know that _N is anything in particular. Some rogue developer might write UseUseNumbers<string, unknown> or UseUseNumbers<"abc","abc" | "def"> or some other crazy off-label use of UseUseNumbers that happens to compile. This is probably unavoidable (although the avoidability or lack thereof is off-topic for the question) but also unlikely to actually happen especially if you document that _N is for "internal use only" (but again, off-topic). How much you want to account for this possibility is up to you.


Anyway, the compiler is upset with

type UseUseNumbers<N extends string & _N, _N = ConstrainNumber<N>> =
  UseNumbers<N> // <-- error

Why? because UseNumbers<N> evaluates to UseNumbers<N, ConstrainNumber<N>> which (similarly to UseUseNumbers) only compiles if N extends string & ConstrainNumber<N>. But the compiler doesn't know what about the current N, which is only known to extend string & _N, and _N is unconstrained. So it doesn't compile.


The way to fix this is to just explicitly take the assumption that nobody's going to mess with _N in UseUseNumbers, and pass this down into UseNumbers:

type UseUseNumbers<N extends string & _N, _N = ConstrainNumber<N>> =
  UseNumbers<N, _N> // <-- this fixes it

Since the _N in UseNumbers is unconstrained, you can write UseNumbers<N, _N>, which will compile if and only if N extends string & _N. But we know that's true by definition of N in UseUseNumbers. As long as people write UseUseNumbers<N> and do not pass a second type argument, then this will evaluate to UseNumbers<N> as desired.

Playground link to code

  •  Tags:  
  • Related