0

I have a Typescript application that, over the course of a series of functions, builds up a complex object with many properties. The object starts off with few properties, and ends with many.

As an example of a function that adds a property to such an object:

interface ComplexObject {
  name: string
  size: number
  shape: string
  numberOfCorners?: number
  internalAngle?: number
  /*... more optional properties */
}

function computeProperties (shape: ComplexObject): void {
  countCorners(shape)
  calculateAngle(shape)
  /*... more functions that add properties */
}

function countCorners (shape: ComplexObject): void {
  if (shape.shape === 'square') {
    shape.numberOfCorners = 4
  } else {
    throw new Error()
  }
}

function calculateAngle (shape: ComplexObject): void {
  // I can guarantee that numberOfCorners exists, but Typescript
  // doesn't know that, so I have to make a non-null assertion (!)
  shape.internalAngle = 360 / shape.numberOfCorners!
}

const myObject: ComplexObject = { name: "Square", size: 20, shape: 'square' }

computeProperties(myObject)

Let's say that there are many different properties to add to this object, and that they are not guaranteed to be added in any particular order.

In order to avoid having to write an interface for each and every permutation of ComplexObject, I've made one long interface with the guaranteed starting properties, and every possible added property as an optional property.

However, I'm now finding myself repeatedly having to assert that a property exists - e.g. shape.numberOfCorners! - because while I can guarantee that an object passed to a given function has a given property, Typescript doesn't know this, because I made them all optional.

Best practice would indicate that at any point I should be checking to see if a property exists and throwing an error accordingly. But I know my own code, I know there are only so many pathways through it, and I know that that isn't necessary. The Typescript compiler should be making that approach obsolete by confirming that these incorrect pathways do not exist.

I feel like I'm working to avoid Typescript, when it should be working for me.


My question: How can I inform Typescript that, for a given function, the object passed to it will have a given set of properties? How can I have Typescript inform me when I pass an object to this function that does not have those properties?

Critically, how can I do so with as little repetition as possible?

2
  • There's no way for typescript to know about dynamically added properties because they can be added at runtime as well. Commented Aug 8, 2020 at 4:26
  • They're not added dynamically, per se - I'm not doing shape[someUnknownProperty] = value in this case. My functions are all nested in one long chain with a single entry point. For any one of those functions, I can assert that the object it takes as argument must have certain properties, and that it adds certain properties to the object, right? Perhaps I have to also return and reassign the modified object. Then the compiler should be able to tell me "you're trying to call countCorners but this object doesn't have the shape property" - right? Commented Aug 8, 2020 at 4:42

1 Answer 1

2

Object mutated inside functions are not yet able to be expressed in Typescript https://github.com/microsoft/TypeScript/issues/22865 without using type assertions.

An alternative, if you are OK with returning a new object instead of mutating the existing one, could be using generics that intersect the new properties to guarantee their existence.

As a heads up, if you do this, you will have to rely heavily on generics throughout your code, so you may be more suitable to just using your optional properties with type assertion.

An example:

interface InitialObject {
  shape: string
  size: number
}

interface RandomProp {
  nested: {randomValue: number}
};

interface RandomProp2 {
  hello: string
};

function addNested<T extends InitialObject> (
  shape: T,
): T & RandomProp {
  return {
    ...shape,
    nested: {randomValue: Math.random()}
  }
}

function addHello<T extends InitialObject> (
  shape: T,
): T & RandomProp2 {
  return {
    ...shape,
    hello: 'string'
  };
}



const myObject: InitialObject = {shape: 'test', size: 4};
const newObj = addNested(myObject); // you can safely access the newObj.nested.randomValue
console.log(newObj.nested.randomValue);
// you cannot access hello
console.log(newObj.hello);

const newObj2 = addHello(newObj); // you can safely acesss both nested and hello
console.log(newObj2.nested.randomValue);
console.log(newObj2.hello);
Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for your response. How well does this scale with many properties? I'm not so sure I like the idea of having to type a deeply-nested function with T & Prop1 & Prop2 & Prop3 & Prop4 ...
@snazzybouche It doesn't scale well. This is mostly a demonstration that it is possible, but not really useful in practice. I would keep an eye on the github issue, and see if typescript ever officially supports mutating objects.
I've been bashing my head against typing this for the past couple of days and have come to the following conclusion: it's just not possible. This isn't what Typescript was designed for. I can't make Typescript "work for me", like I said, because it was never made to do this job. Your suggested solution, the one where I change nothing, does indeed seem like the best one.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.