Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
5 tasks done
KurtGokhan opened this issue Jan 17, 2021 · 20 comments
Open
5 tasks done
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@KurtGokhan
Copy link

KurtGokhan commented Jan 17, 2021

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.

@MartinJohns
Copy link
Contributor

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

@KurtGokhan
Copy link
Author

@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.

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Jan 19, 2021
@RyanCavanaugh
Copy link
Member

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

@JoshuaWise
Copy link

JoshuaWise commented Mar 26, 2021

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).

@bartenra
Copy link

bartenra commented Jul 2, 2021

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)
  }
}

@dospunk
Copy link

dospunk commented Jan 27, 2022

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

@ddejohn
Copy link

ddejohn commented Feb 11, 2022

Also having this issue. See this playground.

@bartenra
Copy link

bartenra commented Mar 30, 2022

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

@Fryuni
Copy link

Fryuni commented Sep 6, 2022

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
Copy link
Contributor

jcalz commented Oct 13, 2022

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") & ...

@Fryuni
Copy link

Fryuni commented Feb 3, 2023

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.

@gordonmleigh
Copy link

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.

@jtbandes
Copy link
Contributor

jtbandes commented Nov 9, 2023

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? πŸ™‚

@DawidKopys
Copy link

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)
        }
    }
}

@jcready
Copy link

jcready commented Jul 5, 2024

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.

@MichalMarsalek
Copy link

MichalMarsalek commented Aug 26, 2024

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.

@AlbertMarashi
Copy link

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)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests