TypeScript
TypeScript copied to clipboard
Allow type variables to be constrained singleton, causing lookup into a non-generic mapped type to substitute
I feel awkward submitting this suggestion since I don't know if it will get enough votes to go anywhere, but I guess someone has to be the initial submitter for each suggestion...
Search Terms
mapped type indexed access type lookup type substitute substitution generic
Suggestion
Currently, a lookup into a mapped type, for example { [P in K]: Box<T[P]> }[X], is simplified by substitution (in this example, to produce Box<T[X]>) only if the constraint type K is generic; this is unsound but I guess it was useful in some cases. I'd like to be able to constrain a type parameter X to be a singleton type, causing substitution to occur (which is sound) regardless of whether K is generic.
Use Case
Suppose we have a codebase with an enum E and many functions that simulate dependent types by taking a type parameter A extends E (where A is intended to be a singleton type) along with a value of type A. Given a generic type T<A extends E>, we may want an object that contains a T<A> for each A in E, i.e., {[A in E]: T<A>}. Then we'd like to pass this object to a function along with a particular choice of A and have it manipulate the corresponding property. We should get a type error if the function uses the wrong property. Currently, a lookup type expression like {[A in E]: T<A>}[A1] does not substitute (because the constraint type E is not generic), so all reads and writes to the property are checked using the constraint of the lookup type, which is {[A in E]: T<A>}[E], and in effect we get no distinction among the properties of the object.
Specifically, I'm writing a structured spreadsheet tool that manipulates row and column IDs. A rectangle ID is a pair of a row ID and a column ID. I wanted to brand the row and column IDs differently to ensure I don't mix them up. I have many functions that are parameterized over an axis: for example, getRectParentOnAxis takes a rectangle and can either find the rectangle that covers the same column and a larger row, or the same row and a larger column.
One current approach, which I've taken and I call the "generic index" hack, is to add an artificial type variable to every relevant type and function so that I can ensure the constraint type of the mapped type is always generic and the mapped type will always substitute. (See "Workaround" below.) This is ugly, but I wanted the checking badly enough to do it.
Examples
enum Axis {
ROW = "row",
COL = "col",
}
const AXIS_BRAND = Symbol();
type SpanId<A extends Axis> = string & {[AXIS_BRAND]: A};
type Rectangle = {[A in Axis]: SpanId<A>};
function getRectangleSide<A in Axis>(rect: Rectangle, a: A): SpanId<A> {
// Error with `A extends axis`: `Rectangle[A]` doesn't simplify and isn't assignable to `SpanId<A>`
// Allowed with `A in Axis`: `Rectangle[A]` simplifies to `SpanId<A>`
return rect[a];
}
function getRectangleSide2<A in Axis>(rect: Rectangle, a: A): Rectangle[A] {
if (Math.random() > 0.5) {
return rect[a];
} else {
// Allowed with `A extends axis`: `SpanId<Axis.ROW>` is unsoundly assignable to `Rectangle[A]`
// because it is assignable to the constraint `SpanId<Axis.ROW> | SpanId<Axis.COL>`
// Error with `A in Axis`: `SpanId<Axis.ROW>` is not assignable to `SpanId<A>`
return rect[Axis.ROW];
}
}
Workaround
const FAKE_INDEX = "fake-index";
type GenericIndex<_, K> = K | (_ & typeof FAKE_INDEX);
type LooseIndex<K> = K | typeof FAKE_INDEX;
enum Axis {
ROW = "row",
COL = "col",
}
type AxisG<_> = GenericIndex<_, Axis>;
type AxisL = LooseIndex<Axis>;
const AXIS_BRAND = Symbol();
type SpanId<A extends AxisL> = string & {[AXIS_BRAND]: A};
type Rectangle<_> = {[A in AxisG<_>]: SpanId<A>};
function getRectangleSide<_, A extends Axis>(rect: Rectangle<_>, a: A): SpanId<A> {
return rect[a]; // allowed
}
function getRectangleSide2<_, A extends Axis>(rect: Rectangle<_>, a: A): Rectangle<_>[A] {
if (Math.random() > 0.5) {
return rect[a];
} else {
return rect[Axis.ROW]; // error
}
}
Checklist
My suggestion meets these guidelines:
- [X] This wouldn't be a breaking change in existing TypeScript / JavaScript code
- [X] This wouldn't change the runtime behavior of existing JavaScript code
- [X] This could be implemented without emitting different JS based on the types of the expressions
- [X] This isn't a runtime feature (e.g. new expression-level syntax)
Would this help with #31672 and #13995?
Hey, I've been digging into related issues lately (#13995 , https://github.com/microsoft/TypeScript/issues/46899 , #27808 , etc), and from what I can tell, @mattmccutchen 's original code example now works without error (after a couple small tweaks).
I.e. this passes now in TS 5.4.5:
enum Axis {
ROW = "row",
COL = "col",
}
const AXIS_BRAND = Symbol();
type SpanId<A extends Axis> = string & { [AXIS_BRAND]: A };
type Rectangle = { [A in Axis]: SpanId<A> };
function getRectangleSide<A extends Axis>(rect: Rectangle, a: A): SpanId<A> {
return rect[a];
}
declare const rect: Rectangle;
const rowSide = getRectangleSide(rect, Axis.ROW); // SpanId<Axis.ROW>
const colSide = getRectangleSide(rect, Axis.COL); // SpanId<Axis.COL>
So it's possible/likely this issue was fixed either by #43183 (which fixed #13995) or a related PR.