lukmac
lukmac

Reputation: 165

TypeScript: Cast whole class type

I am wondering if it is possible to change the type of a class property? But not with a simple cast but rather with overwriting the whole class type.

Let's say I have the following class:

class Test {
  public myNumber = 7;
}

Is it possible to change the type of the myNumber property from number to e.g. string?

Let's assume that we have a custom typescript transformer in place that transforms every property of type number of a class to a string. Then it would be cool to have this reflected somehow in the development process. That's why I am asking if it is possible to adjust the type.

I am looking for an option to override the whole class type definition without casting each property. E.g. not doing this:

const test = new Test();
(
test.myNumber as string).toUpperCase();

My first thought would be that this can maybe be achieved with index types. But I wanted to ask if someone already has experience with this or has a concrete idea.

E.g. it would be cool to call a function...

whatever(Test)

... and after this the type of the class is changed. So the compiler should know from now on that e.g. myNumber is supposed to be of type string and not number.

So this should be possible now:

const test = new Test();
test.myNumber.toUpperCase();

The meaningfulness of this example is not important. It is just a made up use case to illustrate the problem in a (hopefully) simple way.

===

Because the context (how the class will be consumed) was mentioned in the comments of this issue I would like to additionally provide the following example. It is a test (spec) file of an Angular component using jasmine and karma. I tried to explain myself with the comments in the code snippet.

describe('ParentComponent', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;

  /**
   * This function is the "marker" for the custom typescript transformer.
   * With this line the typescript transformer "knows" that it has to adjust the class ParentComponent.
   *
   * I don't know if this is possible but it would be cool if after the function "ttransformer" the type for ParentComponent would be adjusted.
   * With adjusted I mean that e.g. each class property of type number is now reflected as type string (as explained in the issue description above)
   */
  ttransformer(ParentComponent);

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ ParentComponent ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('test it', () => {
    // This cast should not be necessary
    (component.ttransformerTest as jasmine.Spy).and.returnValue('Transformer Test');
    expect(component.ttransformerTest).toHaveBeenCalled();
  });
});

===

Thanks in advance, Lukas

Upvotes: 1

Views: 1805

Answers (2)

Ron S.
Ron S.

Reputation: 679

Looks like you're changing into spy instances. Is that about right?

In any case, I think the simple solution here is to alter the type of component using a mapped type.

Example:

type SpyifyFunctions<T> = {
  // If jasmine.Spy can be used with type arguments, you could supply that here, too
  [K in keyof T]: T[K] extends Function ? jasmine.Spy : T[K]
}

describe('ParentComponent', () => {
  let component: SpyifyFunctions<ParentComponent>;
  let fixture: ComponentFixture<ParentComponent>;

  /**
   * This function is the "marker" for the custom typescript transformer.
   * With this line the typescript transformer "knows" that it has to adjust the class ParentComponent.
   *
   * I don't know if this is possible but it would be cool if after the function "ttransformer" the type for ParentComponent would be adjusted.
   * With adjusted I mean that e.g. each class property of type number is now reflected as type string (as explained in the issue description above)
   */
  ttransformer(ParentComponent);

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ ParentComponent ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance; // If necessary, cast here (as SpyifyFunctions<ParentComponent>)
    fixture.detectChanges();
  });

  it('test it', () => {
    component.ttransformerTest.and.returnValue('Transformer Test');
    expect(component.ttransformerTest).toHaveBeenCalled();
  });
});

Edit

This problem requires an understanding of the Type system in TypeScript. At first, it can be a little difficult to understand when coming from other languages.

We need to first recognize that types exist only during compilation. They're there in order to tell your IDE or the compiler what things should be. This is to help you by providing errors and giving your IDE intellisense. When the code is actually run, types do not exist at all! A good way to get an idea of this is to go to the typescript playground and look at the output javascript (over on the right panel)

Take a look at this example

Click the link above to see how this code is stripped of all type information.

type Spy<TFunction> = { fn: TFunction }

class ParentComponent {
    // We assume an external transformer will change ttransformerTest from a method to a property with the shape { fn: (original method function) }
    ttransformerTest(): string { return '' }
    p!: string
}

type SpyifyFunctions<T> = {
  [K in keyof T]: T[K] extends Function ? Spy<T[K]> : T[K]
}

// Here we're specifically telling TypeScript what Type the component is, so that we don't get errors trying to use component.fn
const component = new ParentComponent() as unknown as SpyifyFunctions<ParentComponent>;

component.ttransformerTest.fn() // <-- No errors, because TypeScript recognizes this as the proper type for component

One key point that should also help gain some understanding is that there is no way to update the type assigned to a reference once it's been assigned.

Bear in mind that the types are walked and assigned before transformers are run. Believe it or not, this is good. It would be chaotic otherwise.

With that in mind:

  • We now know that transformers won't affect the errors in our IDE or compiler, because they happen after the code is already analyzed.
  • Since the JavaScript side is taken care of with the transformers, now we need to find a way to "transform" the actual Type in a way that your IDE can recognize.

Here is one way you can do that with a few helper types. This will probably be the best route to do what you're trying to do, given your use-case.

/* ************************************************************************************* */
// type-transformers.ts
/* ************************************************************************************* */

type Spy<TFunction> = { fn: TFunction }

type SpyifyFunctions<T> = {
  [K in keyof T]: T[K] extends Function ? Spy<T[K]> : T[K]
}

export type MakeSpyableClass<T extends new (...args: any[]) => any> = 
  T extends new (...args: infer Args) => any ? 
    new (...args: Args) => SpyifyFunctions<InstanceType<T>> : never;


/* ************************************************************************************* */
// ParentComponent.ts
/* ************************************************************************************* */
import { MakeSpyableClass } from './type-transformers.ts'

// Note that we do not export this class, directly, since we need TS to infer its type before 
// we "transform" it as an export
class ParentComponentClass {
    // We assume an external transformer will change ttransformerTest from a method to a 
    // property with the shape { fn: (original method function) }
    ttransformerTest(): string { return '' }
    p!: string
}

// Here is where we create a new exported reference to the class with a specific type that we 
// assign We cast to unknown first, so TypeScript doesn't complain about the function shapes 
// not matching (remember, it doesn't know about your tranformer)
export const ParentComponent = 
  ParentComponentClass as unknown as MakeSpyableClass<typeof ParentComponentClass>


/* ************************************************************************************* */
// example.ts
/* ************************************************************************************* */
import { ParentComponent } from './ParentComponent.ts'

const component = new ParentComponent();
component.ttransformerTest.fn() // <-- Recognizes type as Spy instance

See it in Playground

Cautionary note

It's very likely that your approach could be much simpler. It also may have some bad consequences, so I'm adding a few notes that could help.

  • It looks like your class is defined and used outside of just the test environment.
  • It also appears that you'd like to automatically make all the functions into spies during testing.

If that's the case, it would be bad practice to use a transformer to make all of the functions into spies universally. You wouldn't want to implement jasmine code into something running outside of the test environment.

Instead, a better (and far simpler) approach is to write a function that enumerates the property descriptors of the class instance and make its return type use a mapped type so your tests understand what's going on.

A transformer is much more complex than you'd need, and certainly not a good idea to employ for something you only need during testing.

I see you have TestBed.createComponent. Assuming that createComponent exists in the test space only this would likely be where I'd put in logic that:

  1. Iterate Object.getOwnPropertyDescriptors() -> If it's a method or a function, update the object with a Jasmine spy for that method/function
  2. The return type of the function should use the mapped type helper

Something like

type SpyifyFunctions<T> = {
  [K in keyof T]: T[K] extends Function ? Spy<T[K]> : T[K]
}

createComponent<T extends new (...args: any[]) => any>(component: T): SpyifyFunctions<InstanceType<T>> {
// Create new component
// Iterate descriptors and replace with spies
// return component
}

You wanted the least amount of work - trust me when I tell you, a transformer in almost always not the right route to go. 😆

Upvotes: 1

Retsam
Retsam

Reputation: 33449

This is sort of a strange pattern, and not something I'd recommend doing lightly, but it is largely achievable:

Instances and Constructors

When you declare your class Test, Typescript really makes two things: a type named Test which is the instance of a Test class, and a value also named Test which is a constructor that builds the type Test. While TS is able to give these the same name and bring them into scope together with a single class declaration, we'll have to deal with them separately.

Transforming the Instance

So, dealing with the instances, we can write a type that transforms an instance of Test with numbers to an instance with strings:

type NumbersToStrings<T> = {
    [K in keyof T]: T[K] extends number ? string : T[K]
}
type TransformedTest = NumersToStrings<Test>; // Remember, `Test` is the instance not the constructor
Transforming the Constructor

Now we'll need to represent a constructor that builds these TransformedTest instances. We could manually write a constructor with arguments that match the constructor for Test:

type TransformedTestCtor = new(/*constructor arguments*/) => TransformedTest;

Or we can write a type that takes a constructor and returns a constructor that takes the same arg but constructs a different instance:

type ClassTransform<
  OldClass extends new (...args: any[]) => any,
  NewType
> = OldClass extends new (...args: infer Args) => any
  ? new (...args: Args) => NewType
  : never;

// Test - as a value is the constructor, so `typeof Test` is the type of the constructor
type TransformedTestCtor = ClassTransform<typeof Test, TransformedTest>;
Usage

So now we have a constructor that takes the same arguments but returns a different instance. How can we actually use this?

Unfortunately, syntax like this won't work:

whatever(Test)

You can normally change the type of a function's argument using asserts signature, but it doesn't work on classes.

So, there's not really a better approach than just asserting the type:

const TransformedTest = Test as unknown as TransformedTestConstructor;

By naming this constructor the same as the instance type we defined earlier, we mimic the usual pattern where a constructor (value) and an instance (type) share the same name. (And can, e.g. be exported together)

Another option is to put this in a function that returns the transformed type:

function transformType(Type: typeof Test) {
    return Test as unknown as TransformedTestConstructor;
}
const TransformedTest = transformType(Test);

This will return Test as the transformed constructor: but it won't bring TransformedTest into scope as a type, like a normal class - it only brings in the constructor (as a value) into scope. So if Test is mutable and you do:

Test = transformType(Test);

Then the value Test will be the new constructor, but the type will still be the old instance.


Here's the code from this answer in the Typescript Playground

Upvotes: 3

Related Questions