microsoft/TypeScript

Type guard should infer the type of parent object when applied on a property

KurtGokhan opened this issue ยท 22 comments

Suggestion

๐Ÿ” Search Terms

Type guard, parent object, infer, inference

โœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

โญ Suggestion

Typescript should be able to infer the type of an object, if a type guard was checked on a property of the said object. Currently, Typescript does correctly infer the type of the property, but not its parent object.

๐Ÿ“ƒ Motivating Example

The following example is very common in data oriented design.

interface Type1 { list: string[] }

interface Type2 { list: { [key: string]: string } }

declare const obj: Type1 | Type2;

if(Array.isArray(obj.list)) {
    const list: string[] = obj.list; // WORKS
    const map: { [key: string]: string } = obj.list; // ERROR, as expected
    const objCasted: Type1 = obj; // ERROR, unexpectedly
} else {
    const map: { [key: string]: string } = obj.list; // WORKS
    const list: string[] = obj.list; // ERROR, as expected
    const objCasted: Type2 = obj; // ERROR, unexpectedly
}

The following example works and that is good because it is an equally common case in this type of design.

interface Type3 { type: 'type3', data: boolean }

interface Type4 { type: 'type4', data: string }

declare const obj2: Type3 | Type4;

if(obj2.type === 'type3') {
    const objCasted: Type3 = obj2; // WORKS
} else {
    const objCasted: Type4 = obj2; // WORKS
}

So I believe the type guards should work the same way. As far as I see, this does not cause any inconsistency in the language or the type system. It is an improvement without any downsides.

๐Ÿ’ป Use Cases

See the full example.

Quoting @jack-williams:

Type guards do not propagate type narrowings to parent objects. The narrowing is only applied upon access of the narrowed property which is why the destructing function works, but the reference function does not. Narrowing the parent would involve synthesizing new types which would be expensive. More detail in this comment.

Used search terms: type guard property parent

@MartinJohns Thanks but I am trying to understand why. Consider this example:


interface Type5 { type: boolean }

interface Type6 { type: string }

declare const obj3: Type5 | Type6;

if(typeof obj3.type === 'boolean') {
    const objCasted: Type5 = obj3; // ERROR, unexpectedly
} else {
    const objCasted: Type6 = obj3; // ERROR, unexpectedly
}

if(obj3.type === true) {
    const objCasted: Type5 = obj3; // WORKS
} else if(obj3.type === '') {
    const objCasted: Type6 = obj3; // WORKS
}

So if I check with obj3.type === true, it narrows the type of the parent correctly. So the type property is correctly detected as a discriminator property. But type guards don't narrow the the of the parent and that is by choice. Is that because of performance concerns? Type guard seems like a more exhaustive mechanism than simple equality check. I would expect it to be easier to implement.

I want to take another look at this; seems like we might have the right machinery in place now

I ran into this issue today, and would love to see it resolved. In my case, I'm using the excellent SuperStruct package to validate parts of an incoming HTTP request in Koa. I'm using the type guards generated by SuperStruct to validate the query parameters (ctx.query) and request body (ctx.request.body). However, the parent ctx object is not affected by these type guards, so I can't pass the ctx to a type-safe handler (e.g., a handler that expects the ctx.request.body to match a certain schema).

I ran into a similar situation today, which boiled down to (see duplicated issue):

type Slot = { id?: string }

function needsId(slot: { id: string }) {
}

function test(slot: Slot) {
  if (slot.id) {
    needsId(slot)
  }
}

I've run into this issue too. anyone found a work-around yet? I'd really like to not have to repeat type guards all over the place

Also having this issue. See this playground.

Here is a workaround for my use case:

function assertField<T extends { [field in Field]?: T[field] }, Field extends keyof T & string,>(
  obj: T,
  field: Field
): obj is T & { [field in Field]: NonNullable<T[field]> } {
  if (!obj[field]) false;
  return true;
}

type Slot = { id?: string }

function needsId(slot: { id: string }) {
}

function test(slot: Slot) {

  if (slot.id) {
    slot.id;  // string | undefined

    // needsId(slot)  ERROR
  }

  if (assertField(slot, 'id')) {
    slot.id;  // string

    needsId(slot);  // WORKS
  }
}

Playground

I mentioned another workaround on a duplicate issue that I opened, so just sharing it here.

For simple use cases, like checking if a single value is defined or not, there is no need to add a type guard since that would make an extra function call in runtime. Wrap the type and the property here to make it propagate:

type MakeItPropagate<T, K extends keyof T> = 
    | (Omit<T, K> & Partial<Record<K, never>>)
    | (Omit<T, K> & {
        [KI in K]-?: NonNullable<T[KI]>
    });

This makes transforms a record with an optional property into a union using the presence of that property as the discriminator.

Example on the playground.

jcalz commented

This is mentioned in #50891 already linked here, but for visibility:

Now that in-narrowing for unlisted properties is a thing, the suggestion here would be very useful to support runtime type checking of the form if ((x) && (typeof x === "object") && ("foo" in x) && (typeof x.foo === "string") & ...

type Type1 = {
    name: "Type1"
    properties: {
        property: "alpha"
    }
}

type Type2 = {
    name: "Type2"
    properties: {
        property: "beta"
    }
}

let obj: Type1 | Type2 = {} as any

if (obj.properties.property === "beta") {
    obj.name === "Type1" 
    //  ^^^^ tells me it could be Type1 or Type2!?
}

Another minimal example (Playground) which demonstrates the problem. Makes it quite hard to work with complex typed objects.

jcalz commented

Cross-linking #18758 which asks for nested discriminated unions (I imagine this feature would automatically enable that one?)

Hi, I was about to create a ticket for something similar and I think this would be a duplicate of this issue. Can you confirm?

function f(): 0 {

  let x = 10;
  for(;;) {
    if (x === 0){
      return x; // works, expected
    }
  }
}

function g(): {foo: 0} {

  let x = {foo: 10};
  for(;;) {
    if (x.foo === 0){
      return x; // doesn't work, unexpected
    }
  }
}
Output
"use strict";
function f() {
    let x = 10;
    for (;;) {
        if (x === 0) {
            return x;
        }
    }
}
function g() {
    let x = { foo: 10 };
    for (;;) {
        if (x.foo === 0) {
            return x;
        }
    }
}
Compiler Options
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "strictBindCallApply": true,
    "noImplicitThis": true,
    "noImplicitReturns": true,
    "alwaysStrict": true,
    "esModuleInterop": true,
    "declaration": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2017",
    "jsx": "react",
    "module": "ESNext",
    "moduleResolution": "node"
  }
}

Playground Link: Provided

Seems like the same issue @nmattia.

Just as a point for this particular example, if the type were a union of objects with distinct types, it would narrow it down to the correct alternative, that is how tagged unions work after all. The narrowing doesn't work if the union is on the field, or if it is narrowing a field from a non-literal type to a literal type (number to 0 in this case)

So this works, but this doesn't.

I sent a workaround here on #42384 (comment) for narrowing between multiple non-literal types (string | undefined in the example) by converting a record with a union field into a tagged union of records each with a different type for the field. It might be possible to do the same for narrowing number to a literal, but I haven't tried that yet.

The simplest workaround I figured out is:

type RequiredBy<T, K extends keyof T> = Omit<T, K> & Omit<T, K> &
  Pick<Required<T>, K>;

function hasProperty<T extends object, K extends keyof T>(
  obj: T | RequiredBy<T, K>,
  ...keys: K[]
): obj is RequiredBy<T, K> {
  return !keys.some((x) => !obj[x]);
}

Then narrowing works as expected if you use hasProperty to check the existence of a property.

I want to take another look at this; seems like we might have the right machinery in place now

@RyanCavanaugh any chance this improvement could still be achievable in the hopefully-not-too-distant future? ๐Ÿ™‚

I have another reproduction but I am not entirely certain this is the same issue - here.

type MyUnion = {
    arr: string[],
    type: 'one'
} | {
    arr: number[],
    type: 'two'
}


function Test(mu: MyUnion) {
    // here, narrowing works:
    if (mu.type === 'one') {
        // arr[0] is narrowed to 'string' as expected
        mu.arr[0].toLowerCase()
    } else {
        // arr[0] is narrowed to 'number' as expected
        mu.arr[0].toExponential(2)
    }

    // narrowing doesn't work below:
    for (const el of mu.arr) {
        if (mu.type === 'one') {
            // 'el' should be narrowed to 'string' here
            // ... but we get TS error: "Property 'toLowerCase' does not exist on type 'string | number'"
            el.toLowerCase()
        } else {
            // 'el' should be narrowed to 'number' here
            // ... but we get TS error: "Property 'toExponential' does not exist on type 'string | number'"
            el.toExponential(2)
        }
    }
}

Just sharing another use-case which is for interfaces we don't control. For example the GeoJSON spec was created years before TypeScript existed. Working with GeoJSON in TypeScript is rather painful as typically the types are something like:

interface Polygon {
  type: "Polygon";
  coordinates: number[][][];
}

interface LineString {
  type: "LineString";
  coordinates: number[][];
}

// ... more geometry types

type Geometry = Polygon | LineString | ...

type Feature<T extends Geometry = Geometry> = {
  type: "Feature";
  geometry: T;
  properties: {};
}

It's simply not possible to refine a Feature to Feature<Polygon>, but various libraries (that existed before TypeScript) that only take Feature<Polygon> and all I have is a Feature.

It's simply not possible to refine a Feature to Feature<Polygon>, but various libraries (that existed before TypeScript) that only take Feature<Polygon> and all I have is a Feature.

Naturally it's less convenient than if this issue was implemented, but it's usually not too bad to write a sort of typeguard factory for cases like this.

Also this usecase doesn't really need the full generality of this issue, something like #18758 would suffice.

mmm having to end up doing a lot of type assertions to handle this...

I ended up creating a type guard in case anyone wanted some inspiration or an example

type Foo<T> = { inner: T }

function foo_inner_guardX, T extends X, Args extends Array<unknown> = []>(
    value: Foo<X>,
    pred: (inner: X, ...args: Args) => inner is T,
    ...args: Args
): value is Foo<T> {
    return pred(value.inner, ...args)
}

Another repro, using Array#every as a workaround (inefficient). However, it has the caveat that a re-assignment/re-declaration must happen inside the branch, to allow CFA to narrow the destructuring pattern

I've run into a similar issue also regarding whether properties of an object are non-nullable, see example below:

const obj: {foo: string | null} = {foo: ""};

declare function f(arg: {foo: string}): void

if(obj.foo) {
    f(obj) // TYPE ERROR: overall object type not narrowed to have property as non-nullable

    f({foo: obj.foo}) // OK: object property types narrowed when used individually
}

See https://www.typescriptlang.org/play/?#code/MYewdgzgLgBCBGArAXDA3gMxCV0BOAlmAOYwA+MYArgDY0C+MAvOljjAEQf0DcAUHwAmAU2A0AhnmEwMVMMCgFwMgBSTiqTNlxRCJegEpUANxAFBAghhUJEAOjYH0fGK9W2nAek8wAKgE0ABQBRGGCAJXCAeXDUEGNhPHE6OCRRWCgATwAHaTAQWDBJPBAAd2FBGCgQGAALcQSYbJLcvCyYcQhKcABaajpxeBphATdVLXZbB2xDGG8YKIBpOLSFJpbE9qzcrqK8EvLK0trhMBgqCAqYIkECY3MqZJpMvnogA