How to prevent a literal type in TypeScript
Asked Answered
M

1

5

Let's say I have a class like this which wraps over a value:

class Data<T> {
  constructor(public val: T){}

  set(newVal: T) {
    this.val = newVal;
  }
}

const a = new Data('hello');
a.set('world');
// typeof a --> Primitive<string>

So far so good, but now I want to restrict it to only one of a set of types, let's say the primitives:

type Primitives = boolean|string|number|null|undefined;

class PrimitiveData<T extends Primitives> {
  constructor(public val: T){}

  set(newVal: T) {
    this.val = newVal;
  }
}

const b = new PrimitiveData('hello');
b.set('world'); // Error :(

Playground link

That last line fails because b is a Primitive<'hello'> not a Primitive<string>, and so set will only take the literal 'hello' as a value, which clearly isn't what I'm after.

What am I doing wrong here? Without resorting to explicitly widening the types myself (eg: new Primitive<string>('hello')) is there anything I can do?

Motch answered 27/5, 2019 at 20:50 Comment(1)
You can use a conditional type to widen literals; I might write that up if nobody else does before I get to itTitanism
T
10

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

Titanism answered 28/5, 2019 at 0:52 Comment(1)
You definitely delivered on the offer to 'write that up'. :) Excellent information and explanation here, thank you!Motch

© 2022 - 2024 — McMap. All rights reserved.