I am trying to infer all properties of an object as literals.
I know well that I can use the const
assertion.
However, I don't want the resulting type to have readonly
properties.
Here is the issue:
type Options = {
min?: number;
};
class E1<O extends Options> {
constructor(options: O) {}
}
const e1 = new E1({ min: 1 });
typeof e1
is:
E1<{
min: number; // Oh no! This should be `1`
}>
Now, I know that I can use a const
type parameter as described here: TypeScript 5.0 Docs.
Let's try it out:
class E2<const O extends Options> {
constructor(options: O) {}
}
const e2 = new E2({ min: 1 });
typeof e2
is:
E2<{
readonly min: 1; // Now it's `1`. Great! But it's `readonly`?
}>
The result I am desiring is this one:
typeof eN
: (eN
= "Example number N")
EN<{
min: 1;
}>
Now I have tried several solutions, of which none work:
class E3<const C extends Options> {
constructor(c: { -readonly [K in keyof C]: C[K] }) {}
}
const e3 = new E3({ min: 1 });
typeof e3
:
E3<{
readonly min: 1;
}>
Or this one:
type NoRead<T> = {
-readonly [K in keyof T]: T[K];
};
class E4<const C extends NoRead<Options>> {
constructor(c: C) {}
}
const e4 = new E4({ min: 1 });
typeof e4
:
E4<{
readonly min: 1;
}>
There is one obvious solution to this:
class E5<C extends Options> {
constructor(c: C) {}
}
const e5 = new E5({ min: 1 as const });
typeof e5
:
E5<{
min: 1;
}>
However, const assertions become unreasonable as the config object grows in size.
Now, I can already see the question: "Why would you want that?"
The answer is that it's mostly for IntelliSense purposes, to reduce visual load in more complex constructs of this kind.
I hope that is reason enough.
Summary: "I want to infer values of a generic as literals, while avoiding readonly
modifiers."
Here is a Playground link: TS Playground.
I am trying to infer all properties of an object as literals.
I know well that I can use the const
assertion.
However, I don't want the resulting type to have readonly
properties.
Here is the issue:
type Options = {
min?: number;
};
class E1<O extends Options> {
constructor(options: O) {}
}
const e1 = new E1({ min: 1 });
typeof e1
is:
E1<{
min: number; // Oh no! This should be `1`
}>
Now, I know that I can use a const
type parameter as described here: TypeScript 5.0 Docs.
Let's try it out:
class E2<const O extends Options> {
constructor(options: O) {}
}
const e2 = new E2({ min: 1 });
typeof e2
is:
E2<{
readonly min: 1; // Now it's `1`. Great! But it's `readonly`?
}>
The result I am desiring is this one:
typeof eN
: (eN
= "Example number N")
EN<{
min: 1;
}>
Now I have tried several solutions, of which none work:
class E3<const C extends Options> {
constructor(c: { -readonly [K in keyof C]: C[K] }) {}
}
const e3 = new E3({ min: 1 });
typeof e3
:
E3<{
readonly min: 1;
}>
Or this one:
type NoRead<T> = {
-readonly [K in keyof T]: T[K];
};
class E4<const C extends NoRead<Options>> {
constructor(c: C) {}
}
const e4 = new E4({ min: 1 });
typeof e4
:
E4<{
readonly min: 1;
}>
There is one obvious solution to this:
class E5<C extends Options> {
constructor(c: C) {}
}
const e5 = new E5({ min: 1 as const });
typeof e5
:
E5<{
min: 1;
}>
However, const assertions become unreasonable as the config object grows in size.
Now, I can already see the question: "Why would you want that?"
The answer is that it's mostly for IntelliSense purposes, to reduce visual load in more complex constructs of this kind.
I hope that is reason enough.
Summary: "I want to infer values of a generic as literals, while avoiding readonly
modifiers."
Here is a Playground link: TS Playground.
You can achieve this by using "reverse mapped types" (see this comment on microsoft/TypeScript#53018), also known as "inference from mapped types" (as documented in the deprecated TS Handbook, but not for some reason in the current one, even though nothing has changed about this).
That is, if you have a generic call or construct signature of the form function foo<T>(ft: F<T>): void
then TypeScript can sometimes infer the type parameter T
from a value of the mapped type F<T>
. That's only possible when the mapped type F<T>
is homomorphic (What does "homomorphic mapped type" mean?) in T
. It's a "reverse" mapped type because, if you call foo(fa)
, TypeScript has to run the mapped type in reverse, getting the input T
from the output F<T>
.
For your example code, it would look like this:
class E<const C extends Options> {
constructor(c: Readonly<C>) { }
}
Here we're still using a const
type parameter C
to encourage inference of literal types. But we're saying that the type of the constructor argument c
is Readonly<C>
, using the Readonly
utility type (implemented as the homomorphic mapped type type Readonly<T> = { readonly [P in keyof T]: T[P]; }
).
What it means is this: when you call new E(c)
, TypeScript will look at the type of c
as Readonly<C>
. In order to infer C
from the type of c
, it needs to reverse the operation of Readonly<>
. Which means C
is conceptually the non-readonly version of typeof c
:
const e = new E({ min: 1, str: "abc" });
/* const e: E<{ min: 1; str: "abc"; }> */
And apparently that works. The const
type parameter behaves as if you called new E({min: 1, str: "abc"} as const
, which infers c
as being of type {readonly min: 1, readonly str: "abc"}
, and since that's Readonly<C>
, TypeScript matches C
with {min: 1, str: "abc"}
. It's actually a bit surprising to me that this works, since it is arguably correct for C
to still be {readonly min: 1, readonly str: "abc"}
; since Readonly<C>
would be the same either way. But luckily for you, TypeScript decides to actually apply the equialvent of the -readonly
mapping modifier.
In the cases where reverse mapped types don't magically work, you'd need to do it yourself. That is, if new <C extends Options>(c: Readonly<C>) => E<C>
wasn't doing it, then new <C extends Options>(c: C) => E<Mutable<C>>
should do it, where type Mutable<T> = { -readonly [K in keyof T]: T[K] }>
. Unfortuately TypeScript would never infer such a thing for a class
declaration, meaning you'd need to write it out yourself manually and assign a constructor to it:
class _E<C extends Options> {
constructor(c: C) { }
}
type E<C extends Options> = _E<C>;
const E: new <const C extends Options>(
e: C) => E<{ -readonly [K in keyof C]: C[K] }> = _E;
And that gives the same result:
const e = new E({ min: 1, str: "abc" });
/* const e: E<{ min: 1; str: "abc"; }> */
This is more complicated though, so reverse mapped types are preferable when they work.
Playground link to code
class
declaration. If you're willing to write out the types like this playground link shows then does it meet your needs? If so I could write an answer explaining; if not, what's missing? – jcalz Commented Jan 2 at 17:54exactOptionalPropertyTypes
enabled in my project. That gives me an error, in my and your code. flag docs. I can't solve this with theExtract
utility type. Any idea what is going on? Or is that a seperate issue? – Janek Eilts Commented Jan 2 at 19:48E<{ -readonly [K in keyof C]: C[K] } & Options>
, or open a new question about it, but it's really not on topic for the question as asked. I'll write an answer given your originalclass
thing (I mean, it's up to you if you want to turn it into a function, but that's outside the scope of the examples as written). – jcalz Commented Jan 2 at 20:09E3
was doing the reverse of what you wanted. You were sayingc
should be a non-readonly version ofC
, but it's actually the opposite. If that works better for you I'll write that as an answer. – jcalz Commented Jan 2 at 20:46