Reputation: 326
I'm trying to create a type that defines the value based on the key. If the key extends $${string}
(e.g. $foo
) the value should be the key without the prefix e.g. foo
. If the key doens't extend $${string}
(e.g. boo
) the values should be null
.
Example
const example = {
$foo: 'foo',
boo: null,
}
Here is an isolated example I created to get it done - but it doesn't work as intended when I apply it to the code below. 😕
type Value<T> = T extends `$${infer I}` ? I : null
type ExampleA = Value<'$foo'> // type is 'foo'
type ExampleB = Value<'boo'> // type is null
My current code
type Values = {
[K in string]: K extends `$${infer p}` ? p : null;
}
const values = {
$foo: 'foo', // Type 'string' is not assignable to type 'null'.
foo: null,
$boo: 'boo', // Type 'string' is not assignable to type 'null'.
boo: null,
} satisfies Values;
type Expected = {
readonly $foo: 'foo',
readonly foo: null,
readonly $boo: 'boo',
readonly boo: null,
}
The satisfies Values
is used to infer the type later on. Similar approach is acceptable🙂
Thanks for your help and time - cheers
Upvotes: 1
Views: 106
Reputation: 328453
The problem with your Values
type is that mapped types over string
do not behave the way you expect them to. While conceptually you can think of string
as the infinite union of all possible string literal types, a mapped type over string
does not even try to iterate over every possible string literal type; it just maps one thing: string
:
type Values = {
[K in string]: K extends `\$${infer S}` ? S : null;
}
/* type Values = {
[x: string]: null;
} */
And since string
does not extend `\$${infer S}`
, then the property type for the string
key is null
.
This is working as intended, as discussed in microsoft/TypeScript#22509. Mapped types over string
are not what you want.
And unfortunately there is no way to write a specific type in TypeScript which behaves the way you want. The closest you could get is something like
type Values = {
[k: `\$${string}`]: string;
[k: string]: string | null;
}
using a template string pattern index signature, but the parts where the property value string needs to match the part after the "$"
character (not just string
) and the part where other keys need to have a null
(not just string | null
) cannot be represented:
const values = {
$foo: 'foo',
foo: null,
$boo: 'boo',
boo: null,
$oops: null, // error, not string
oops: 'hmm', // should be error, but isn't!
$whoops: 'oops', // should be error, but isn't!
} satisfies Values;
So we have to give up on the approach using the satisfies
operator, because there is no appropriate Values
type to use it with.
What you really care about is having the type of values
inferred by the compiler but still checked against your desired constraint. We can get behavior like this by replacing satisfies Values
with a generic helper function we can call satisfiesValues()
. At runtime this function just returns its input, but the compiler can use it to validate the object literal passed in. So instead of const values = {...} satisfies Values;
you would write const values = satisfiesValues({...});
.
Here's one possible implementation:
const satisfiesValues = <K extends PropertyKey>(
val: { [P in K]: P extends `\$${infer S}` ? S : null }
) => val;
The function is generic in K
, the keys of the val
value passed in. This will most likely be some union of known keys (none of which will be just string
), and then the mapped type behaves as desired:
const values = satisfiesValues({
$foo: 'foo',
foo: null,
$boo: 'boo',
boo: null,
$oops: null, // error, not "oops"
oops: 'hmm', // error, not null
$whoops: 'oops', // error, not "whoops"
});
/* const values: {
foo: null;
$foo: "foo";
boo: null;
$boo: "boo";
oops: null;
$whoops: "whoops";
$oops: "oops";
} */
Looks good. The type of values
is what you want it to be, and the compiler allows the valid properties and complains about the invalid ones.
Upvotes: 2