Mutations in TypeScript

In this article, I will describe some problems you can encounter mutating objects in typescript.

I have noticed that few people on StackOverflow had issues with mutations in typescript.

Most of the time, it looks like a bug for us, but it is not.

Let's start from type system itself.

type User = {
    name: string;
}

Is it possible to mutate this type?

How would you change the type of name property to number?

There are several ways to do this:

type User = {
    name: string;
}

type User1 = User & {
    name: number;
}

type User2 = {
    [P in keyof User]: P extends 'name' ? number : User[P]
}

type User3 = Omit<User, 'name'> & { name: number }

As you might have noticed, non of them mutate the type, only overrides the property.

I think this is the most natural way of dealing with objects in TypeScript.

First and foremost, you should definitely watch Titian-Cernicova-Dragomir's talk about covariance and contravariance in TypeScript.

This example, is shamelessly stolen 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 student: 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 
 */
let test: Covariance<Type> = employeeInBox

test.box = student // mutation of employeeInBox

// while result_0 is undefined, it is infered a a string
const result_0 = employeeInBox.box.salary 


/**
 * MUTATION
 */
let array: Array<Type> = []
let employees = [employee]
array = employees
array.push(student)

// while result_1  is [string, undefined], it is infered as string[]
const result_1 = employees.map(elem => elem.salary)

There is a lot going on here.

If you are curious how to avoid such behavior, all you need is to make values immutable.

Try to add readonly flag to Covariance and use ReadonlyArray

type Covariance<T> = {
   readonly box: T
}

let array: ReadonlyArray<Type> = []

However, if you are planning to mutate your objects, you should be aware about some issues you can face.

First issue

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] // error

}

It is might be not obvious, but this is expected behavior.

While both input and output share same type, they could have different value.

type KeyType_ = "B" | "D" | undefined

let keyB: KeyType_ = 'B';
let keyD: KeyType_ = 'D'

output[keyB] = input[keyD] // Boom, illegal state! Runtime error!

Second example

const foo = <T extends { [key: string]: any }>(obj: T) => {
    obj['a'] = 2 // error
}

This behavior is expected, because mutating obj argument can lead to runtime errors.

let index: { [key: string]: any } = {}

let immutable = {
    a: 'a'
} as const

let record: Record<'a', 1> = { a: 1 }

index = immutable // ok
index = record // ok

const foo = <T extends { [key: string]: any }>(obj: T) => {
    obj['a'] = 2 // error

    return obj
}

const result1 = foo(immutable) //  unsound, see return type 
const result2 = foo(record) // unsound , see return type

As you see, TS has some mechanisms to avoid unsound mutations. But, unfortunately, it is not enough.

Try to use Reflect.deleteProperty or delete operator

let index: { [key: string]: any } = {}

let immutable = {
  a: 'a'
} as const

let record: Record<'a', 1> = { a: 1 }

index = immutable // ok
index = record // ok

const foo = <T extends { [key: string]: any }>(obj: T) => {
  Reflect.deleteProperty(obj, 'a') // or delete obj.a

  return obj
}

const result1 = foo(immutable) //  unsound, see return type 
const result2 = foo(record) // unsound , see return type

However, we still can't remove property from object which has explicit type:

type Foo = {
  age: number
}

const foo: Foo = { age: 42 }

delete foo.age // error

Third issue

Consider this example:

const paths = ['a', 'b'] as const

type Path = typeof paths[number]

type PathMap = {
    [path in Path]: path
}

const BASE_PATHS = paths.reduce((map: PathMap, p: Path) => {
    let x = map[p]
    map[p] = p // error
    return map
}, {} as PathMap)

Here you see an error because objects are contravariant in their key types

What does it mean ?

Multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.

Simple example:

type a = 'a'
type b = 'b'

type c = a & b // never

Official explanation:

Improve soundness of indexed access types #30769

With this PR we improve soundness of indexed access types in a number of ways:

  • When an indexed access T[K] occurs on the source side of a type relationship, it resolves to a union type of the properties selected by T[K], but when it occurs on the target side of a type relationship, it now resolves to an intersection type of the properties selected by T[K]. Previously, the target side would resolve to a union type as well, which is unsound.
  • Given a type variable T with a constraint C, when an indexed access T[K] occurs on the target side of a type relationship, index signatures in C are now ignored. This is because a type argument for T isn't actually required to have an index signature, it is just required to have properties with matching types.
  • A type { [key: string]: number } is no longer related to a mapped type { [P in K]: number }, where K is a type variable. This is consistent with a string index signature in the source not matching actual properties in the target.
  • Constraints of indexed access types are now more thoroughly explored. For example, given type variables T and K extends 'a' | 'b', the types { a: T, b: T }[K] and T are now considered related where previously they weren't.

Some examples:

function f1(obj: { a: number, b: string }, key: 'a' | 'b') {
    obj[key] = 1;    // Error
    obj[key] = 'x';  // Error
}

function f2(obj: { a: number, b: 0 | 1 }, key: 'a' | 'b') {
    obj[key] = 1;
    obj[key] = 2;  // Error
}

function f3<T extends { [key: string]: any }>(obj: T) {
    let foo = obj['foo'];
    let bar = obj['bar'];
    obj['foo'] = 123;  // Error
    obj['bar'] = 'x';  // Error
}

function f4<K extends string>(a: { [P in K]: number }, b: { [key: string]: number }) {
    a = b;  // Error
    b = a;
}
Enter fullscreen mode Exit fullscreen mode

Previously, none of the above errors were reported.

Fixes #27895. Fixes #30603.

Btw, for similar reason you have this error:

type A = {
  data: string;
  check: (a: A['data']) => string
}

type B = {
  data: number;
  check: (a: B['data']) => number
}

type C = {
  data: number[];
  check: (a: C['data']) => number
}

type Props = A | B | C;

const Comp = (props: Props) => {
  // check(a: never): string | number
  props.check()

  return null
}

Because function arguments are in contravariant position they are cause intersection.

19