Mutations in TypeScript
yossarian
Posted on June 30, 2021
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 byT[K]
, but when it occurs on the target side of a type relationship, it now resolves to an intersection type of the properties selected byT[K]
. Previously, the target side would resolve to a union type as well, which is unsound. - Given a type variable
T
with a constraintC
, when an indexed accessT[K]
occurs on the target side of a type relationship, index signatures inC
are now ignored. This is because a type argument forT
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 }
, whereK
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
andK extends 'a' | 'b'
, the types{ a: T, b: T }[K]
andT
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;
}
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.
Posted on June 30, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024
November 27, 2024