almostalx
almostalx

Reputation: 107

Map one type to another in typescript

I'm trying to map/match a class type to another type via a generics. An example will make that clearer:

class BaseClass {
  //...
  public someGenericClassStuff: 'someGenericClassStuff' = 'someGenericClassStuff';
}
class BaseProps {
  //...
  public someGenericStuff: 'someGenericStuff' = 'someGenericStuff';
}


class FooClass extends BaseClass {
  //...
  public someFooClassStuff: 'someFooClassStuff' = 'someFooClassStuff';
}
class FooProps extends BaseProps {
 public someFooSpecificStuff: 'someFooSpecificStuff' = 'someFooSpecificStuff';
 //...
}


class BarClass extends BaseClass{
  //...
  public someBarClassStuff: 'someBarClassStuff' = 'someBarClassStuff';
}
class BarProps extends BaseProps {
 public someBarSpecificStuff: 'someBarSpecificStuff' = 'someBarSpecificStuff';
 //...
}


type AllClass = BarClass | FooClass;
type AllProps = BarProps | FooProps;

type SpecificProps<T extends AllClass> = // Wondering what goes here
 
const test: SpecificProps<BarClass> = new BarProps(); // Works
const test2: SpecificProps<FooClass> = new FooProps(); // Works
const test3: SpecificProps<BarClass> = new FooProps(); // Fails

I tried a few options, here's what is getting me the closest:

type SpecificProps<T extends AllClass> = 
  T extends BarClass ? BarProps : 
  T extends FooClass ? FooProps :
  never;

This solution works pretty decently in my example above, but when I start adding more and more classes and Unions between them it starts to fall apart and sometimes will return me wrong types. Also it's not the most convenient to write as I have a lot more than 2 classes and each time I add one I need to augment this type too.

My ideal solution would be to do something like

class BarClass extends BaseClass {
  //...
  public someBarClassStuff: 'someBarClassStuff' = 'someBarClassStuff';

  type props = BarProps;
}

// And my type would become
type SpecificProps<T extends AllClass> = T['props'];

But I know this doesn't exist in typescript. I would have to create an actual variable instead of a type and it would take up memory for nothing other than the type matching.

Here's a TS Playground of the above example

Upvotes: 3

Views: 2541

Answers (2)

Gerrit Begher
Gerrit Begher

Reputation: 403

Here's another example of a solution that works solely on the type level. It even works for arbitrary types and is not restricted to classes. (Playground link)

// ------------------------------------
// `Registry.ts`


interface Registry {}

type lookup<T> =
    {
        [k in keyof Registry]:
            Registry[k] extends [T, infer P] ? P : never
    }[keyof Registry]


// ------------------------------------
// `Class1.ts`


type Class1 = "C1"
type Props1 = "P1"

interface Registry {
    someKey: [Class1, Props1]
}


// ------------------------------------
// `Class2.ts`


type Class2 = "C2"
type Props2 = "P2"

interface Registry {
    someOtherKey: [Class2, Props2]
}


// ------------------------------------
// `SomeOtherPlaceInYourCode.ts`


// Correctly resolves to `"P1"`
type test1 = lookup<Class1>
// Correctly resolves to `"P2"`
type test2 = lookup<Class2>
// Correctly resolves to `never`
type test2 = lookup<"something else">

Upvotes: 1

jcalz
jcalz

Reputation: 330456

What you're looking for is something like "type families" for TypeScript, where you can associate types with other types, instead of having to drop down to the value level and create (or pretend to create) unnecessary-at-runtime properties to do so. There's an open GitHub request at microsoft/TypeScript#17588 asking for this, but I don't see much movement there.


You've already got a workaround involving building your own conditional type; there are probably tweaks to this which might behave better for your edge cases, but without a minimal, reproducible example of such edge cases I won't bother trying to go down that road.


Instead, let's look at a workaround that is similar to your desired type family solution. We create a "phantom" property value in each XXXClass class, which doesn't actually exist at runtime, despite your claim that it does:

class BaseClass {
  public someGenericClassStuff: 'someGenericClassStuff' = 'someGenericClassStuff';
  declare __phantomPropsType: BaseProps;
}

Here I'm using the declare property modifier. It has no affect on runtime, but now TypeScript thinks that every BaseClass has a property named __phantomPropsType. You can squint and read that as type propsType = BaseProps if you want. Here's the rest of them:

class FooClass extends BaseClass {
  public someFooClassStuff: 'someFooClassStuff' = 'someFooClassStuff';
  declare __phantomPropsType: FooProps;
}

class BarClass extends BaseClass {
  public someBarClassStuff: 'someBarClassStuff' = 'someBarClassStuff';
  declare __phantomPropsType: BarProps;
}

And now your SpecificProps falls out of it with a simple lookup, and no need to build a union called AllClass:

type SpecificProps<T extends BaseClass> = T['__phantomPropsType']

This works the same as your example:

const test: SpecificProps<BarClass> = new BarProps(); // Works
const test2: SpecificProps<FooClass> = new FooProps(); // Works
const test3: SpecificProps<BarClass> = new FooProps(); // Fails

It's a workaround because the compiler really does think there's this __phantomPropsType property. The double underscore is kind of a hint that it's a phantom property (you tend to see this in other usages like branded types), but it will still pop up "helpfully" in IntelliSense, etc:

const n = new FooClass();
n.__phantomPropsType // eh, not great

Playground link to code

Upvotes: 3

Related Questions