How to selectively assign from one Partial to another in typescript

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

Leave a Comment