3

I'm new to Typescript, so probably not using the terminology right... bear with me

Background

I've been trying to extend primitives such as string with moderate success. I am even able to store some custom type info on my extended string, like this:

// yes, we can extends strings!
type ExtendStr<P extends string = string, MaxLen extends number = 0> = P & { parent: P, options: { max: MaxLen } }

// create a LongStr with max length of 256
type LongStr = ExtendStr<string, 256>

I am able to retrieve the extra info that I stored in the type information, like this:

// get the max length of a LongStr
type LongStrMax = LongStr['options']['max']
// yields 256... Hoorah! 

I can even extend LongStr and get correctly working type widening/narrowing:

// make a ShortStr that extends LongStr but has max length 8
type ShortStr = ExtendStr<LongStr, 8>

// two example variables
let short: ShortStr = 'Hello' as ShortStr 
let long: LongStr = 'Omg this is a very long string!!' as LongStr 

// widening conversion is allowed as it should
long = short 

// narrowing conversion gives compiler error... sweet! 
short = long 

However, this seems to 'hide' the stored info...

Now the question

I want to get at the stored 'max' for the ShortStr. I see it there in the type info...

But when I try to get it...

type ShortStrMax = ShortStr['options']['max']

..it yields never.... is there a way???

Playground

Stijn de Witt
  • 40,192
  • 13
  • 79
  • 80
  • You’re aware of the reason right? `8 & 256` should be `never`. – hackape Aug 07 '22 at 15:40
  • 1
    Does [this approach](https://tsplay.dev/NByaVw) meet your needs? Instead of representing the max length you represent a union of acceptable lengths, so the intersection works the way you want (instead of `8 & 256`=`never` you get `(0|1|...|7|8)&(0|1|...|255|256)`=`(0|1|...|7|8)`. If that works for you I can write up an answer; if not, what am I missing? – jcalz Aug 07 '22 at 19:36
  • @jcalz Can't wait to see some wizardry – hackape Aug 08 '22 at 03:54
  • The semantic of your current impl is really describing "fixed sized string" rather than "string with max length". And reason why TS allows `long = short` is only a coincident, because `short.options.max == never` and `never` can be assigned to anything. – hackape Aug 08 '22 at 04:01
  • If you define `ShortStr` first, then `type LongStr = ExtendStr`, now you'll see `short = long` is allowed, but not `long = short`, which I doubt is what you want. Thus I think jcalz's solution is the right way to go. – hackape Aug 08 '22 at 04:03
  • @jcalz That seems quitte brilliant, Thank you! Still got to wrap my head around it but this looks very promising indeed! – Stijn de Witt Aug 09 '22 at 05:54
  • @jcalz Did not notice right away, but your solution actually addresses 2 questions I had. You show how to get at the `max` property (and how to store it so you can retrieve it later), but your solution actually also narrows/widens correctly! Mine only did if I created a complex hierarchy. Yours does it really based on the length you filled in. That's brilliant stuff! Please post this as an answer so I can accept it and we can all give you the *rep* you so obviously deserve :) – Stijn de Witt Aug 09 '22 at 14:29
  • I will write up an answer when I get the chance, hopefully soon – jcalz Aug 09 '22 at 15:08

2 Answers2

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
1

Extending the String type to have a max/min length is not completely trivial. You can see this question about how to do it Declaring string type with min/max length in typescript

What is going wrong with your types starts here

type ShortStr = ExtendStr<LongStr, 8>

You can inspect the type and see what ShortStr gets expanded into

type ShortStr = string & {
    parent: string;
    options: {
        max: 256;
    };
} & {
    parent: LongStr;
    options: {
        max: 8;
    };
}

You can see that the options properties are not compatible with each other, because the max property is deemed by the compiler to not ever be able to satisfy max === 8 and max === 256.

Therefore, options gets reduced to never, but you can still access parent, since the types are compatible.

type ShortStrParent = ShortStr['parent']
// expands to
type ShortStrParent = string & {
    parent: string;
    options: {
        max: 256;
    };
}
Alberto Rivera
  • 3,652
  • 3
  • 19
  • 33