0

I have 2 similar setups that I would expect to produce the same results but the second one throws a Typescript error:

First:

interface ISample {
  requiredProp: string
  optionalProp?: string
}

const sampleObj: ISample = { requiredProp: "hello" }

const { requiredProp, optionalProp = "default" } = sampleObj

console.log(requiredProp, optionalProp)

// Expected Output: "hello", "default"
// Actual Output:   "hello", "default"

Second:

interface ISample {
  requiredProp: string
  optionalProp?: string
}

const sampleObj: ISample | {} = { requiredProp: "hello" } // object can now be empty

const { requiredProp = "default", optionalProp = "default" } = sampleObj

console.log(requiredProp, optionalProp)

// Expected Output: "hello", "default"
// Actual Output:   Error: Property 'requiredProp' does not exist on type '{} | ISample'. Property 'optionalProp' does not exist on type '{} | ISample'.

Yes thank you Typescript I know that these props might not exist on that empty object that's why I'm immediately assigning them default values on the same line...

I need to make the object optionally empty and still be able to destructure undefined values from them so I can either assign them default values (like in the example above) or use them with the optional chaining operator. How do I get rid of this useless error without typing the object to any?

9
  • Why? If the requiredProp doesn't have to exist on your object, it's not really required. If you don't know what props you are going to have on the object, use a Record. If you know that it will be from a selection of properties but want to allow any field to be ommited, use Partial. Otherwise, always provide the required props and don't type it as ISample | {}. Commented Aug 23, 2021 at 16:05
  • requiredProp has to exist if the object is not empty. The object can be empty because the app might still be in a fetching state and waiting for data, but when the data comes in it has to match the interface. Commented Aug 23, 2021 at 16:12
  • That's a better use case for null rather than {}. Possibly include a boolean hasLoaded in your state. Commented Aug 23, 2021 at 16:39
  • I'd still like to know if there's any explanation why Typescript won't let me access optional union object properties even when I provide default values. Commented Aug 23, 2021 at 17:33
  • Because one of the possible states of sampleObj is {} wherein requiredProp isn't available. With the union you have, you cannot successfully access any properties of sampleObj without asserting a type. Commented Aug 23, 2021 at 17:38

1 Answer 1

1

In short, you cannot access ISample properties on sampleObj because TypeScript cannot inferentially discriminate the {} member of the union ISample | {} since both union members are satisfied by the assignment. Thus, you cannot access a property on sampleObj unless it's present on all members of the union (which rules out all properties).

Unions

From the handbook:

A union type is a type formed from two or more other types, representing values that may be any one of those types. We refer to each of these types as the union’s members.

Remember, unions do not combine types (that's the role of an intersection); they give you options for what a thing (variable, parameter, property, etc.) might be at assignment. For example:

// foo, by annotation, might be a string or a number...
let foo: string | number = 'bar';
// ... but it is assigned to a string, so its realized type is string:
foo.toUpperCase(); // ok, because foo: string

// We can reassign foo as long as it is still a member of the original annotated union
foo = 0;
foo.toUpperCase(); // error, because foo: number
//  ^^^^^^^^^^^ Property 'toUpperCase' does not exist on type 'number'

Here based on assignment a narrower type of foo was inferred (or rather, union member string was discriminated).

To re-emphasize, discriminating a union member (at assignment or otherwise) disallows those members until re-assignment, i.e. you can't do this:

let foo: { bar: string } | { baz: number } = { bar: 'foobar' }
foo.baz = 0
//  ^^^ Property 'baz' does not exist on type '{ bar: string; }'.
foo = { baz: 0 } // ok

The empty type {}

Typing a thing as {} can lead to some behaviors that feel unexpected but are totally valid.

... the empty type seems to defy expectation:

class Empty {}
 
function fn(arg: Empty) {
  // do something?
}
 
// No error, but this isn't an 'Empty' ?
fn({ k: 10 });

Consider the following valid TypeScript:

let foo: {} = {};
foo = { bar: 0 };
foo = 'bar';
foo = [ true, 0, '' ];

These are all valid because every property on {} is present on each of these actual types. If we changed it up:

  let foo: { length: number } = { length: 0 };
  const o = { bar: 0 }
  foo = o;
//^^^ Property 'length' is missing in type '{ bar: number; }' 
//      but required in type '{ length: number; }'
  foo = 'bar';
  foo = [ true, 0, '' ];

Here, all but one of these satisfy length: number. {} has no properties to satisfy, so anything goes (except null and undefined).

Also note that the {} type is practically useless without type assertions:

let foo: {} = 'bar';
foo.length;
//  ^^^^^^ Property 'length' does not exist on type '{}'

Conclusion/Answer

So, looking again at the posted example:

interface ISample {
  requiredProp: string
  optionalProp?: string
}

const sampleObj: ISample | {} = { requiredProp: "hello" } // object can now be empty

const { requiredProp = "default", optionalProp = "default" } = sampleObj
//      ^^^^^^^^^^^^              ^^^^^^^^^^^^
// Property 'requiredProp' does not exist on type '{} | ISample'
// Property 'optionalProp' does not exist on type '{} | ISample'.

console.log(requiredProp, optionalProp)

When assigning the initial value of sampleObj, TypeScript tries to narrow the type by discriminating members that do not match the given value. In this case, it cannot rule out any members of the union since { requiredProp: "hello" } satisfies both ISample and {}.


For the sake of being thorough, note that const { requiredProp = "default", optionalProp = "default" } = sampleObj is trying to access sampleObj.requiredProp and sampleObj.optionalProp, which cannot exist on type {} | ISample. Your options are:

  • change ISample so that it can allow for an empty object (by making all properties optional).
  • change the type of sampleObj to use Partial<ISample>, which accomplishes the same thing without changing ISample's definition.
  • always assign the required properties of things typed ISample to default values.

Regardless of your choice, you should probably remove {} from the union to avoid a headache (though you might look into type predicates).

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.