When it comes to directly supporting your actions in TypeScript, there are some major challenges you'll encounter:
- The type of variables needs to evolve over time.
- These changes must be noticeable across different function scopes.
Absence of Arbitrary Type Mutations
In TypeScript, you can't simply change the type of an expression or variable at will. For example, you cannot do something like
let foo = {a: "hello"}; foo = {b: 123};
and expect the compiler to switch the type of
foo
from
{a: string}
to
{b: number}
. While you can define the type of
foo
as
let foo: {a?: string; b?: number}
, the compiler won't detect any changes occurring between assignments.
All that TypeScript offers is narrowing through control flow analysis. It allows you to make a variable's apparent type more specific using assignments, user-defined type guards, or assertion functions. However, these narrowing effects don't extend across function boundaries.
Lack of Type Narrowing Effects Across Functions
The inability for changes to propagate across function boundaries makes it challenging for TypeScript to assist in tracking varying types. Because only changes within the body of a function are visible to callers, implementing a general approach to persist type narrowing effects would severely impact compiler performance. This limitation has been discussed in detail in GitHub issue microsoft/TypeScript#9998.
This situation leaves you somewhat constrained.
Instead of struggling with these constraints, my suggestion is to align your code with what TypeScript comprehends well. It is best practice to maintain a variable's type consistent throughout its lifespan. Rather than modifying config
by calling initConfig()
, consider utilizing the parameters of initConfig
to construct myFunction()
. This approach involves creating a factory-like structure using class
:
class Impl<T extends typeof DefaultClass = typeof DefaultClass> {
class: T
constructor(classType: T = DefaultClass as T) {
this.class = classType;
}
myFunction(param: any) {
return new this.class(1) as InstanceType<T>;
}
}
const CurImpl = new Impl(MyClass);
const myInstance: MyClass = CurImpl.myFunction("something");
const DifferentImpl = new Impl();
const differentInstance: DefaultClass = DifferentImpl.myFunction("else");
With this setup, CurImpl
remains aware of MyClass
since it belongs to Impl<typeof MyClass>
and retains a consistent type. If you wish to use an alternative class constructor, create a new instance of Impl<T>
for another T
.
In the above scenario, I utilized a generic parameter default to set typeof DefaultClass
as the fallback when inferring T
. In case the classType
parameter is omitted, the compiler resorts to using DefaultClass
. Although this method may lack full type safety, it assumes that users won't misuse T
with calls like new Impl<typeof MyClass>()
. To handle this effectively, a type assertion was employed to assure the compiler of DefaultClass
's compatibility with type T
if no classType
is specified. There could be other potentially safer ways to achieve this (possibly eliminating the need for a class
), but I digress from the main topic.
Playground link to code