Mutations in TypeScript
Tags: typescript, javascript
Published: 2021-06-30 18:26:15

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.

```typescript
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:
```typescript
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.

{% youtube RH49aarW6sU %}  

This example, is shamelessly stolen from Titian's talk
```typescript
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`

```typescript
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**

```typescript
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.
```typescript
type KeyType_ = "B" | "D" | undefined

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

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

**Second example**
```typescript

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.
```typescript

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

```typescript
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:
```typescript
type Foo = {
  age: number
}

const foo: Foo = { age: 42 }

delete foo.age // error
```
**Third issue**

Consider this example:
```typescript
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:
```typescript

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

type c = a & b // never
```

Official explanation:

{% github https://github.com/microsoft/TypeScript/pull/30769 %}

Btw, for similar reason you have this error:
```typescript

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.