JH-
JH-

Reputation: 392

Clean way to narrow object property types in typescript

In the below code, it is difficult to understand to me just why Typescript is able to properly narrow down the type of x when accessed separately but not on the object as a whole. Since o.x is "the property x on the object o", it seems incongruent that the type of "o.x" and the type of x as seen on o could possibly be different.

function checkPropType(o: {x: number | undefined, s: string}) {
  o.x ??= 5;

  takeNumber(o.x); // Great!

  takeOWithDefinedX(o) // T_T
  takeOWithDefinedX({...o, x: o.x}); // Works but ugly and wasteful.
}

function checkPropType2(o: {x: number | undefined, s: string}) {
  let {x} = o;
  x ??= 5;

  takeNumber(x); 

  takeOWithDefinedX(o) // T_T
  takeOWithDefinedX({...o, x}) // Works, but the destructuring makes it even more verbose overall
}

function takeNumber(x: number) {}
function takeOWithDefinedX(o: {x: number, s: string}) {}

https://tsplay.dev/NddKyN

Whatever the reason may be, it looks like we need to deal with it for now. I've shown two possible ways to do this, both of them involving creating an entire new object just to satisfy the TS compiler. The JS output shows it is not compiled away either. takeOWithDefinedX(Object.assign(Object.assign({}, o), { x: o.x }));

Is there a cleaner way to handle this issue?

Upvotes: 2

Views: 1729

Answers (3)

Hunkoys
Hunkoys

Reputation: 260

One way is to use Discriminated Unions.

// Solution 1: Discriminated Union
function checkPropType3(o: { x: number; s: string } | { x?: never; s: string }) {
  let { x = 5 } = o;

  takeNumber(x);

  if (typeof o.x === 'number') {
    takeOWithDefinedX(o); // o: { x: number; s: string }; ^_^
  }
}
// Solution 1 End

// Solution 2: Discriminated Union with generics
function checkPropType4<T extends { x: number; s: string } | { x?: never; s: string }>(o: T) {
  let { x = 5 } = o;

  takeNumber(x);

  if (typeof o.x === 'number') {
    takeOWithDefinedX(o); // o: { x: number; s: string }; ^_^
  }
}
// Solution 2 End

function takeNumber(x: number) {}
function takeOWithDefinedX(o: { x: number; s: string }) {
  console.log(o);
}

By creating a sub-type for an object with x and another without (you can do: x?: never), you're telling typescript that "The variable can either be an object with an x or without", which allows you to narrow the entire object itself, instead of "the object has a property x that can either be a number or undefined", which only allows you to narrow the property.

Typescript Handbook: Discriminated Unions

Upvotes: 0

2oppin
2oppin

Reputation: 1991

I get your frustration, indeed you have an assign in your code to ensure that now your "x" is defined, but I don't think TS will be able to check all the code for you;

here's official, but a bit outdated answer

So you have 3 choices:

  1. Ignore TS

    if it's non-complex and rarely used types
    

    takeOWithDefinedX(o as {x: number; s:string})

  2. use @jsejcksn answer

    The same idea as "1",

    just a general hack, saying, "Yeah, yeah, - I'm sure I've checked all properties"

  3. Create conversion function

    type IAll = { x: number; s: string } // don't use "I" for the prefix, can't come up with something short )))
    type IOpt = { x?: number | undefined; s: string };
    
    function ensure(opt: IOpt, x = 5): IAll {
      let a: IAll = {x, s: opt.s};
      if (opt.x) a.x = opt.x;
      return a;
    }
    
    function checkPropType(o: IOpt) {
      takeOWithDefinedX(ensure(o)) // -_0
    }
    
    function takeNumber(x: number) {}
    function takeOWithDefinedX(o: IAll) {}
    
    
    

tsplay

Upvotes: 1

jsejcksn
jsejcksn

Reputation: 33730

You could create a custom utility which takes a type and a prop as generic arguments, and it creates a mapped type which sets the type of the value of the prop to be that type excluding undefined. Then use it to assert that o is the type that you need. This emits no extra runtime code (check the JS output on the right in the playground link).

TS Playground

type RequiredProp<T, K extends keyof T> = T & Record<K, Exclude<T[K], undefined>>;

function takeNumber(x: number) {}
function takeOWithDefinedX(o: {x: number, s: string}) {}

function checkPropType(o: {x: number | undefined, s: string}) {
  o.x ??= 5;
  takeNumber(o.x);
  takeOWithDefinedX(o as RequiredProp<typeof o, 'x'>);
}

Upvotes: 1

Related Questions