I don't think it is a bug, it is almost always unsafe to mutate the values and TS just tries to make it safe.
Let's start from InjectMap
interface.
It is clear that you cant have illegal state like:
const illegal: InjectMap = {
"A": "D", // expected B
"C": "B" // expected D
}
This is important.
Let's proceed with our loop:
interface InjectMap {
"A": "B",
"C": "D"
}
type InjectKey = keyof InjectMap;
const input: Partial<InjectMap> = {};
const output: Partial<InjectMap> = {};
const keys: InjectKey[] = []
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const inp = input[key] // "B" | "D" | undefined
const out = output[key] // "B" | "D" | undefined
output[key] = input[key]
}
Because key
is dynamic, TS is unsure whether it B
, D
or undefined
. I hope that you are agree with me, that in this place correct type of inp
is "B" | "D" | undefined
, it is expected behavior, because type system is static.
Since, input
and output
are not binded by key
, TS wants to avoid illegal state. To make it clear, consider next example, which is equal to our
type KeyType_ = "B" | "D" | undefined
let keyB: KeyType_ = 'B';
let keyD: KeyType_ = 'D'
output[keyB] = input[keyD] // Boom, illegal state! Runtime error!
As you might have noticed, keyB
and keyD
have the same type but different values.
Same situation you have in your example, TS is unable to figure out the value it is able to figure out only type.
If you want to make TS happy, you should add condition statement or typeguard:
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key === 'A') {
let out = output[key] // "B"
let inp = input[key] // "B"
output[key] = input[key] // ok
}
if (key === 'C') {
let out = output[key] // "D"
let inp = input[key] // "D"
output[key] = input[key] // ok
}
}
Please keep in mind, when you mutate your values, you loose type guaranties.
See this and this question about mutations.
Also this talk of Titian Dragomir Cernicova is pretty good.
Here you have an example of unsafe mutation, taken from @Titian 's talk:
type Type = {
name: string
}
type SubTypeA = Type & {
salary: string
}
type SubTypeB = Type & {
car: boolean
}
type Extends<T, U> =
T extends U ? true : false
let employee: SubTypeA = {
name: 'John Doe',
salary: '1000$'
}
let human: Type = {
name: 'Morgan Freeman'
}
let director: SubTypeB = {
name: 'Will',
car: true
}
// same direction
type Covariance<T> = {
box: T
}
let employeeInBox: Covariance<SubTypeA> = {
box: employee
}
let humanInBox: Covariance<Type> = {
box: human
}
// Mutation ob object property
let test: Covariance<Type> = employeeInBox
test.box = director // mutation of employeeInBox
const result_ = employeeInBox.box.salary // while result_ is undefined, it is infered a a string
// Mutation of Array
let array: Array<Type> = []
let employees = [employee]
array = employees
array.push(director)
const result = employees.map(elem => elem.salary) // while salary is [string, undefined], is is infered as a string[]
console.log({result_,result})
Playground
How to fix it ?
Please, let me know if it works for you:
export interface InjectMap {
"A": "B",
"C": "D"
}
const assign = <Input extends InjectMap, Output extends InjectMap>(
input: Partial<Input>,
output: Partial<Output>,
keys: Array<keyof InjectMap>
) => keys.reduce((acc, elem) => ({
...acc,
[elem]: input[elem]
}), output)
Playground
UPDATE
why does this analysis apply for [elem]: input[elem]? input[elem] can again be "B"|"D"|undefined and thus the compiler should give error again. But, here compiler is intelligent to know that the type of input[elem] applies for [elem]. What is the difference?
That is a very good question.
When you create new object with computed key, TS makes this object indexed by string
, I mean, you can use any string prop
you want
const computedProperty = (prop: keyof InjectMap) => {
const result = {
[prop]: 'some prop' // { [x: string]: string; }
}
return result
}
It gives you more freedom but also provides a bit of unsafety.
With great power comes great responsibility
Because now, unfortunately, you can do this:
const assign = <Input extends InjectMap, Output extends InjectMap>(
input: Partial<Input>,
output: Partial<Output>,
keys: Array<keyof InjectMap>
) => keys.reduce((acc, elem) => {
return {
...acc,
[elem]: 1 // unsafe behavior
}
}, output)
As you might have noticed, return type of assign
function is Partial<Output>
, which is not true.
Hence, in order to make it completely type safe you can use with typeguards, but I think it will be overcomplicating