When you write
type ShortStr = ExtendStr<LongStr, 8>
and
long = short
it seems you'd like a ShortStr
to be a subtype of LongStr
. That is, every string of 8 or fewer characters is also a string of 256 or fewer characters. That makes sense. But your definition of ExtendStr<T, N>
says that there will be an options.max
property of type N
. A ShortStr
would therefore need to have an options.max
property whose value is both 8
and 256
. There's no value of this type, so this property type is equivalent to the never
type and things start behaving strangely.
Conceptually it makes more sense to imagine the length
property of the string. A value of ExtendStr<string, 256>
should have a length
property whose value is some non-negative whole number less than or equal to 256
. You can represent this as a union type like 0 | 1 | 2 | ... | 254 | 255 | 256
. And ExtendString<string, 8>
should have a length
property of type 0 | 1 | 2 | ... | 6 | 7 | 8
. Now it's definitely possible for a string to have a length
property of both those types, since the latter is strictly a subtype of the former. So if we can programmatically generate the right union type of numbers given N
, we can write ExtendStr
in terms of it.
Here's one way to do it:
type LessThan<N extends number, A extends number[] = []> =
N extends A['length'] ? A[number] : LessThan<N, [A['length'], ...A]>;
type LessThanOrEqual<N extends number> = N | LessThan<N>
The LessThan<N>
type is a tail-recursive conditional type that turns a number literal type like 10
into a union of the nonnegative integers less than it by building up a tuple of these values and stopping when the tuple has length N
. So 5
would become [0, 1, 2, 3, 4]
which becomes 0 | 1 | 2 | 3 | 4
.
And LessThanOrEqual<N>
is just the union of N
with LessThan<N>
, so LessThanOrEqual<5>
is 0 | 1 | 2 | 3 | 4 | 5
.
And now ExtendStr
:
type ExtendStr<P extends string = string, MaxLen extends number = 0> =
P & { length: LessThanOrEqual<MaxLen> }
Note that instead of creating phantom parent
and options.max
properties, I just use the string type itself and the existing length
property. You could keep it your way if you want, but I don't see much of a use for it in this example. It's up to you.
One more thing... you want to be able to extract the maximum length from the type. That is, given ExtendStr<string, N>
, you'd like to retrieve N
. Right now if you inspect the length
property you get a big union, and you just want the maximum member of that union. Well, you can do that like this:
type Max<N extends number> = Exclude<N, LessThan<N>>
That works because LessThan<3 | 5>
will be 0 | 1 | 2 | 3 | 4
and Exclude<3 | 5, 0 | 1 | 2 | 3 | 4>
is 5
.
So, let's try it:
type LongStr = ExtendStr<string, 256>
type LongStrMax = Max<LongStr['length']>
/* type LongStrMax = 256 */
type ShortStr = ExtendStr<LongStr, 8>
let short: ShortStr = 'Hello' as ShortStr
let long: LongStr = 'Omg this is a very long string!!' as LongStr
long = short // okay
short = long // error
type ShortStrMax = Max<ShortStr['length']>;
//type ShortStrMax = 8
Looks good!
The above works well enough for me, but recursive conditional types can be tricky and sometimes cause compiler performance issues. If that happens you might want to revert to your current version and then deal with the problem some other way.
Playground link to code