2

In the following snippet, is it demonstrated that Typescript cannot warn against mutation of strictly typed objects in a way that violates the constraints of its type?

type Animal = {
  name: 'cat'
  noise: 'meow'
} | {
  name: 'dog'
  noise: 'woof'
};


const f = (animal: Animal) => {
  // Why is this allowed? Typescript still considers this to be of type 'Animal'
  // after this mutation, even though its value no longer assignable to any variant
  // of Animal.
  animal.noise = 'woof';

  // ❌ - Typescript failed to update animal's type when we mutated it, so now
  // it mistakenly continues to believe it is assignable to Animal everywhere!
  // Now its incorrectly typed data can spread throughout the application,
  // breaking type safety, potentially everywhere...
  const anotherAnimal: Animal = animal;

  // ✅ - Typescript correctly prevents us from assigning this combination of fields
  // to type Animal. But this is exactly the value we mutated to in our first example,
  // so why were we allowed to do that when the outcome is the same, invalid result?
  const animalMatchingPostMutationValue: Animal = {
    name: 'cat',
    noise: 'woof'
  }
}

const animal: Animal = { 
  name: 'cat',
  noise: 'meow',
}

f(animal)

See playground link here.

Am I mistaken in believing this should be prevented? If not, is there a way other than simply avoiding mutations (perhaps using readonly to enforce this practice)?

EDIT

Reworked example to avoid casting.

David Wood
  • 123
  • 8

1 Answers1

1

You should not use as Animal in

const animal: Animal = { 
  name: 'cat',
  noise: 'meow',
} as Animal

it doe not make sense and makes your code unsafe.

Please see the docs

Type assertions are a way to tell the compiler “trust me, I know what I’m doing.” A type assertion is like a type cast in other languages, but it performs no special checking or restructuring of data. It has no runtime impact and is used purely by the compiler. TypeScript assumes that you, the programmer, have performed any special checks that you need.

Once you have written as Animal, TypeScript assumes that any Animal is assignable to appropriate property:

const animal: Animal = {
  name: 'cat',
  noise: 'meow',
} as Animal

animal.name // "cat" | "dog"
animal.noise // "meow" | "woof"

It is no more able to distinguish is it a part of union or not.

type Name = Animal['name'] // "cat" | "dog"
type Noise = Animal['noise'] // "meow" | "woof"

This is why type assertions are unsafe and should be considered only if you don't have any other option.

Mutations are a bit unsafe operations in typescript because TS does not track them except one case.

You can find more information about mutations in typescript in my article:

TL;DR

Just try to avoid mutations in typescript

List of answers related to mutations in typescript

How to selectively assign from one Partial to another in typescript,

Why can I index by string to get a property value but not to set it?

TypeScript: Why can't I assign a valid field of an object with type { a: "a", b: "b" }

Why does Typescript say this variable is "referenced directly or indirectly in its own initializer"?

https://github.com/microsoft/TypeScript/pull/30769

keyof and React Redux action

Cannot dynamically set interface object's property values

Convert Object.entries reduce to Generics type

Summary The main problem is that object mutable properties are covariant. COnsider this example stolen from Titian-Cernicova-Dragomir 'stalk:

type Type = {
    name: string
}

type SubTypeA = Type & {
    salary: string
}

type SubTypeB = Type & {
    car: boolean
}

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

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

console.log({ result_0 })

result_0 is undefined whereas TS think that it is a string. In order to fix it, just use readonly flag for COvariance type:

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

Now it fails as it should be.

Workaround, without readonly

type Cat = {
  name: 'cat'
  noise: 'meow'
}

type Dog = {
  name: 'dog'
  noise: 'woof'
};

type Animal<Name, Noise> = { name: Name, noise: Noise }

const f = <
  CatName extends Cat['name'],
  CatNoise extends Cat['noise'],
  DogName extends Dog['name'],
  DogNoise extends Dog['noise'],
  Pet extends Animal<CatName, CatNoise> | Animal<DogName, DogNoise>
>(animal: Pet) => {

  animal.noise = 'woof'; // error

  const anotherAnimal: Pet = animal;

  // error
  const animalMatchingPostMutationValue: Animal = {
    name: 'cat',
    noise: 'woof'
  }
}
const animal: Animal = {
  name: 'cat',
  noise: 'meow',
}

f(animal) // ok

Playground You can make it contravariant. Does not look nice but works.

You can use eslint plugin to enforce readonly flags

  • Thanks for your response. Perhaps I am mistaken, but it seems my example demonstrates the same behaviour even when casting is removed (see edited snippet and playground). I only cast in the original example due to concerns that Typescript would infer a narrower object literal type if I didn't. – David Wood Oct 29 '21 at 11:04
  • 1
    @DavidWood made an update – captain-yossarian from Ukraine Oct 29 '21 at 12:14
  • Thanks again, your examples and links gave me a lot to look into as I was unfamiliar with the terms "covariant" and "contravariant". From your statement "The main problem is that object mutable properties are covariant", am I right to interpret (A) that Typescript allows property assignments in which the assigned value is a SUBTYPE of the properties type (i.e. properties are covariant), and (B) this results in unsound behaviour in my example, since although Cat['noise'] is a subtype(?) of Animal['noise'], the former cannot actually be safely assigned to the latter? – David Wood Oct 30 '21 at 11:02
  • 1
    @DavidWood I wish I can answer your question without a shadow of doubt but I can't. I have my own guesses but I don't want to mislead you. I think it worth posting as a separate question. – captain-yossarian from Ukraine Oct 30 '21 at 17:09