Home > database >  Extract info from Typescript type
Extract info from Typescript type

Time:08-10

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

CodePudding user response:

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;
    };
}

CodePudding user response:

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

  • Related