TypeScript intentionally infers literal types just about everywhere, but usually widens those types except in a few circumstances. One is when you have a type parameter which extends
one of the widened types. The heuristic is that if you're asking for T extends string
you might care to keep the exact literal. This is still true with unions, like T extends Primitives
, so you get this behavior.
We can use conditional types to force (unions of) string, number, and boolean literals to widen to (unions of) string
, number
, and boolean
:
type WidenLiterals<T> =
T extends boolean ? boolean :
T extends string ? string :
T extends number ? number :
T;
type WString = WidenLiterals<"hello"> // string
type WNumber = WidenLiterals<123> // number
type WBooleanOrUndefined = WidenLiterals<true | undefined> // boolean | undefined
Now this is great, and one way you might want to proceed is to use WidenLiterals<T>
in place of T
everywhere inside PrimitiveData
:
class PrimitiveDataTest<T extends Primitives> {
constructor(public val: WidenLiterals<T>){}
set(newVal: WidenLiterals<T>) {
this.val = newVal;
}
}
const bTest = new PrimitiveDataTest("hello"); // PrimitiveDataTest<"hello">
bTest.set("world"); // okay
And that works as far as it goes. bTest
is of type PrimitiveDataTest<"hello">
, but the actual type of val
is string
and you can use it as such. Unfortunately, you get this undesirable behavior:
let aTest = new PrimitiveDataTest("goodbye"); // PrimitiveDataTest<"goodbye">
aTest = bTest; // error!
// PrimitiveDataTest<"hello"> not assignable to PrimitiveDataTest<"goodbye">.
// Type '"hello"' is not assignable to type '"goodbye"'.
This seems to be due to a bug in TypeScript where conditional types are not being checked properly. The types PrimitiveDataTest<"hello">
and PrimitiveDataTest<"goodbye">
are each structurally identical to each other and to PrimitiveDataTest<string>
, so the types should be mutually assignable. That they are not is a bug which may or may not get addressed in the near future (maybe some fixes are set for TS3.5 or TS3.6?)
If that's okay then you can probably stop there.
Otherwise, you might consider this implementation, instead. Define an unconstrained version like Data<T>
:
class Data<T> {
constructor(public val: T) {}
set(newVal: T) {
this.val = newVal;
}
}
And then define the type and value PrimitiveData
as related to Data
like this:
interface PrimitiveData<T extends Primitives> extends Data<T> {}
const PrimitiveData = Data as new <T extends Primitives>(
val: T
) => PrimitiveData<WidenLiterals<T>>;
The pair of type and value named PrimitiveData
acts like a generic class where T
is constrained to Primitives
, but when you call the constructor the resulting instance is of the widened type:
const b = new PrimitiveData("hello"); // PrimitiveData<string>
b.set("world"); // okay
let a = new PrimitiveData("goodbye"); // PrimitiveData<string>
a = b; // okay
That might be easier for users of PrimitiveData
to work with, although the implementation of PrimitiveData
does require a bit of hoop jumping.
Okay, hope that helps you move forward. Good luck!
Link to code