Reputation: 165
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
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();
});
});
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:
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
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.
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:
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
Reputation: 33449
This is sort of a strange pattern, and not something I'd recommend doing lightly, but it is largely achievable:
Instances and ConstructorsWhen 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.
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