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

Suggestion: treat `in` operator as type guard which asserts property existence #21732

Open
jcalz opened this issue Feb 7, 2018 · 42 comments
Open

Comments

@jcalz
Copy link
Contributor

@jcalz jcalz commented Feb 7, 2018

TypeScript Version: 2.8.0-dev.20180204

Search Terms: in operator type guard generic assert

Code

function f<K extends string, T>(key: K, genericObj: T, concreteObj: {foo: string}) {
  if ('a' in concreteObj) {
    concreteObj.a // error, Property 'a' does not exist on type 'never'.
  }
  if ('a' in genericObj) {
    genericObj.a // error, Property 'a' does not exist on type 'T'.
  }
  if (key in concreteObj) {
    concreteObj[key]; // error, Type 'K' cannot be used to index type '{ foo: string; }'
  }
  if (key in genericObj) {
    genericObj[key] // error, Type 'K' cannot be used to index type 'T'.
  }
}

Actual behavior:
The compiler does not recognize that the objects have relevant keys even after checking for the existence of the key with the in operator. According to a comment by @sandersn, the in type guard (as implemented in #15256) narrows by eliminating members from a union; it does not assert that a key exists.

Desired behavior:
The compiler would assert that each object had a property with the relevant key, after having checked for the existence of the key with the in operator. Note that one possible implementation of an asserting type guard would look like

function inOperator<K extends string, T>(k: K, o: T): o is T & Record<K, unknown> {
  return k in o;
}

but this does not behave exactly as desired, possibly due to the bug in #18538:

function g<K extends string, T>(key: K, genericObj: T, concreteObj: {foo: string}) {
  if (inOperator('a', concreteObj)) {
    concreteObj.a // okay
  }
  if (inOperator('a', genericObj)) {
    genericObj.a // okay
  }
  if (inOperator(key, concreteObj)) {
    concreteObj[key]; // error, Type 'K' cannot be used 
    // to index type '{ foo: string; } & Record<K, unknown>'
  }
  if (inOperator(key, genericObj)) {
    genericObj[key] // error, Type 'K' cannot be used 
    // to index type 'T & Record<K, unknown>'.
  }
}

If a fix for #18538 appears and makes the g() function compile without error, great. Otherwise, maybe the property assertion for in could happen some other way. Not sure.

Playground Link: Here

Related Issues:
#10485, Treat in operator as type guard
#15256, Add type guard for in keyword
#18538, Error when mixing keyof and intersection type and type variable

(EDIT: change any to unknown)

@mhegazy

This comment has been minimized.

Copy link

@mhegazy mhegazy commented Feb 7, 2018

related discussion also in #10715 about type guards "evolving" the type of an expression.

@mhegazy mhegazy added the Suggestion label Feb 7, 2018
@GregRos

This comment has been minimized.

Copy link

@GregRos GregRos commented Feb 12, 2018

This! Currently, we have the odd situation that when you write:

if ("assign" in Object) {
    Object.assign(target, ...args);
}

When the ES6 typings aren't loaded, it will narrow Object down to never in the if statement!

@mhegazy

This comment has been minimized.

Copy link

@mhegazy mhegazy commented Feb 12, 2018

@GregRos please see #21517

@GregRos

This comment has been minimized.

Copy link

@GregRos GregRos commented Feb 14, 2018

@mhegazy Yup, I know. I was giving it as an example for what this suggestion would fix. I know why it's happening.

@mattmccutchen

This comment has been minimized.

Copy link
Contributor

@mattmccutchen mattmccutchen commented Jul 17, 2018

Let's add some other ways of asserting property existence from #25720 to this proposal:

let x: unknown;

// All these should narrow x to {prop: unknown} (s/any/unknown/ from original proposal by Matt)
"prop" in x;
x.prop != null;
x.prop !== undefined;
typeof x.prop !== "undefined";

// typeof should work on properties of the unknown variable
typeof x.prop === "string"; // should narrow x to {prop: string}
@mhegazy

This comment has been minimized.

Copy link

@mhegazy mhegazy commented Jul 27, 2018

Similar requests in #10715, #25720, and #25172

@leighman

This comment has been minimized.

Copy link

@leighman leighman commented Aug 9, 2018

@mattmccutchen I think you'd want those to narrow to {prop: unknown} rather than {prop: any} wouldn't you?

@felipeochoa

This comment has been minimized.

Copy link

@felipeochoa felipeochoa commented Aug 12, 2018

@mattmccutchen The problem is that trying to access a property on null/undefined will throw an error at runtime. I would support adding those type guards, but only on the object type. (If there were a way to get an unknownWithoutNullOrUndefined type, that would be even better)

@RyanCavanaugh

This comment has been minimized.

Copy link
Member

@RyanCavanaugh RyanCavanaugh commented Aug 13, 2018

@jcalz can you highlight the differences between #10485 and what you'd want to happen?

@jcalz

This comment has been minimized.

Copy link
Contributor Author

@jcalz jcalz commented Aug 14, 2018

TL;DR: #10485 narrows by filtering union constituents. This suggestion narrows by adding properties to the type.

In cases where y is not a union type or none of the constituents have an explicit property named x, the test if (x in y) should not try to filter union constituents, but should instead narrow y by intersecting its type with Record<typeof x, unknown>.


In #10485, the type of the object you're checking with in is meant to be a union, and the type guard filters that union based on whether the constituents do or do not explicitly have a property of the relevant name:

type A = { w: string, x: number };
type B = { y: string, z: number };
function foo(p: A | B): number {
  if ('w' in p) {
    return p.x; // p is narrowed to A
  } else {
    return p.z; // p is narrowed to B
  }
}

Note that this isn't exactly sound, since you can call

foo({ y: "oops", z: 100, w: true }); 

but the point of property checking on unions is usually to use that property as a discriminant, and the sound behavior would probably annoy the heck out of developers.


Compare to the following:

function bar(p: {}): string {
  if ('w' in p) {
    return String(p.w); // error?! ☹
  } else {
    return "nope";
  }
}

It is surprising that after what feels like an explicit test for p.w, the compiler still doesn't know that p.w exists. Worse, p is narrowed to never, probably because of #10485.

What I want to see happen is:

  • Only do the narrowing in #10485 if the type is a union where at least one constituent explicitly features the relevant property (edit: optional properties count as "explicit" also).
  • Otherwise (or afterwards), have the x in y check be equivalent to the type guard y is typeof y & Record<typeof x, unknown> (modulo the bug in #18538).
@sirian

This comment has been minimized.

Copy link

@sirian sirian commented Aug 19, 2018

@jcalz This guard doesn't work with partial interfaces

function foo(x: {foo: "bar"} | {toFixed?: () => any}) {
  if (inOperator("toFixed", x)) {
    x.toFixed(); // Error, Object is of type unknown
  }
}
@jcalz

This comment has been minimized.

Copy link
Contributor Author

@jcalz jcalz commented Aug 19, 2018

@sirian Yeah, strictly speaking, all you know is that x.toFixed exists, since a value of type {foo: "bar"} may contain a toFixed property (e.g.,

foo(Object.assign({ foo: "bar" as "bar" }, { toFixed: "whoops" }));  // no error

), but assuming people still want the current behavior in #10485 where we eliminate {foo: "bar"} from consideration as soon as we find a toFixed property, then this suggestion is to apply the inOperator() behavior after that elimination.

So in your case, x would first be narrowed to {toFixed?: () => any} as per #10485 and then to {toFixed: (() => any) | undefined} via something like inOperator()... meaning that toFixed is definitely there but it might be undefined (since ? is ambiguous about whether the property is actually missing or present but undefined.)

If you want a better idea what the behavior would end up being like, try the following complex user-defined type guard using conditional types:

type Discriminate<U, K> = ( U extends any ? K extends keyof Required<U> ? U : never : never ) 
  extends infer D ? [D] extends [never] ? U : D : never
        
function hybridInOperator<K extends keyof any, T>(
  k: K, 
  o: T
): o is Discriminate<T, K> & Record<K, unknown> {
    return k in o;
}

producing:

function foo(x: { foo: "bar" } | { toFixed?: () => any }) {
    if (hybridInOperator("toFixed", x)) {
      x.toFixed(); // error, possibly undefined
      if (x.toFixed) x.toFixed();   // okay
    }
}

Does that seem better?

@sirian

This comment has been minimized.

Copy link

@sirian sirian commented Aug 20, 2018

@jcalz
Better, but there is another problem with type infer. Look at example

function foo(x: { foo: "bar" } | Number | { toFixed?: () => any }) {
    if (hybridInOperator("toFixed", x)) {
        x.toFixed(); // no error. since x resolved as Number
        if (x.toFixed) x.toFixed();   // okay
    }
}

I also tried various type guards. But always found a new counterexample(

Upd. As a quick fix - if you change o is Discriminate<T, K> & Record<K, unknown> to o is Extract<T, Discriminate<T, K>>. then x will be resolved as Number | { toFixed?: () => any }

Upd2.

So in your case, x would first be narrowed to {toFixed?: () => any} as per #10485 and then to {toFixed: (() => any) | undefined}

Not right... toFixed would be narrowed to unknown... Look at example. So and error in #21732 (comment) screenshot was not "object is possibly undefined"

image

@jcalz

This comment has been minimized.

Copy link
Contributor Author

@jcalz jcalz commented Aug 20, 2018

I don't know how worthwhile it is to come up with a user-defined type guard which exactly mimics the desired behavior of in narrowing, in light of the variety of edge cases. In this case, something weird is happening, since the hybridOperator() returns something like x is Number | { toFixed: undefined | (() => any) }; which is wider than Number. The fact that x narrows to Number inside the then-clause looks like some kind of compiler bug (edit: as per #26551, it is not considered a bug, but it is inconsistent and makes it more difficult to design a perfect type guard). I'd rather not get sidetracked here with that.

The "quick fix" has toFixed as optional, but it should be required... which is why I was intersecting with {toFixed: unknown} in the first place.

re: Upd2, I think you're missing that the suggestion is to apply the inOperator() behavior after the elimination that happens in #10485 ? Not sure if I wasn't clear about that.

@sirian

This comment has been minimized.

Copy link

@sirian sirian commented Aug 20, 2018

@jcalz Maybe add smth like & Pick<Required<T>, K & keyof <Required<T>>>? I'll try to experiment at home )

@sirian

This comment has been minimized.

Copy link

@sirian sirian commented Aug 20, 2018

@jcalz what about this one?

type Discriminate<U, K extends PropertyKey> =
    U extends any
    ? K extends keyof U ? U : U & Record<K, unknown>
    : never;

function inOperator<K extends PropertyKey, T>(k: K, o: T): o is Discriminate<T, K> {
    return k in o;
}

function foo(x: null | string | number | Number | { toFixed?: () => any } | { foo: 1 }) {
    if (inOperator("toFixed", x)) {
        // x is number | Number | { toFixed?: () => any | undefined }
        x.toFixed && x.toFixed();
    }
}

function g<K extends string, T>(key: K, genericObj: T, concreteObj: {foo: string}) {
    if (inOperator('a', concreteObj)) {
        concreteObj.a // unknown
    }
    if (inOperator('a', genericObj)) {
        genericObj.a // any
    }
    if (inOperator(key, concreteObj)) {
        concreteObj[key]; // unknown
    }
    if (inOperator(key, genericObj)) {
        genericObj[key] // any
    }
}
@jcalz

This comment has been minimized.

Copy link
Contributor Author

@jcalz jcalz commented Aug 20, 2018

Looks good at first glance!

@sirian

This comment has been minimized.

Copy link

@sirian sirian commented Aug 21, 2018

@jcalz There is a dilemma. how should work typeguard?

function foo(x: {foo?: number} | {bar?: string}) {
    if (inOperator("foo", x)) {
        // x is {foo: number | undefined}
        // or
        // x is {foo: number | undefined} | {foo: unknown, bar?: string} 
    }
}

or in this case:

function foo(x: {foo?: number} | string) {
    if (inOperator("foo", x)) {
        // x is {foo: number | undefined}
        // or
        // x is {foo: number | undefined} |  string & {foo: unknown} 
    }
}
@jcalz

This comment has been minimized.

Copy link
Contributor Author

@jcalz jcalz commented Aug 21, 2018

It should be the first one in both cases, {foo: number | undefined}, as implied by the bulleted list at the bottom of this comment.

@sirian

This comment has been minimized.

Copy link

@sirian sirian commented Aug 21, 2018

@jcalz So if we extract types rather than widen it with additional property - what should be at this examples?

function foo(x: {bar?: number}) {
    if (inOperator("foo", x)) {
        // x is never? so is x.foo not accessible?
    }
}
function foo(x: object) {
    if (inOperator("foo", x)) {
        // x is never? so is x.foo not accessible?
    }
}
@jcalz

This comment has been minimized.

Copy link
Contributor Author

@jcalz jcalz commented Aug 21, 2018

It should be {bar?: number, foo: unknown} and {foo: unknown} respectively, as implied by the same bulleted list. I can't tell if that isn't clear or if you're not reading it. 🤕

  • Only do the narrowing in #10485 if the type is a union where at least one constituent explicitly features the relevant property (edit: optional properties count as "explicit" also).
  • Otherwise (or afterwards), have the x in y check be equivalent to the type guard y is typeof y & Record<typeof x, unknown> (modulo the bug in #18538).
@lifeiscontent

This comment has been minimized.

Copy link

@lifeiscontent lifeiscontent commented Apr 10, 2019

@DanielRosenwasser any thoughts on when this might be looked at by the TS team?

@sirian

This comment has been minimized.

Copy link

@sirian sirian commented Apr 15, 2019

@Raynos you can create helper function like this

export type TypeNameMap = {
    function: Function;
    object: object | null;
    string: string;
    number: number;
    boolean: boolean;
    symbol: symbol;
    bigint: bigint;
    undefined: undefined | void;
};
export declare type TypeName = keyof TypeNameMap;

function has<K extends PropertyKey, T extends TypeName>(obj: any, key: K, type: T): obj is {[P in K]: TypeNameMap[T]}  {
    return (key in obj) && type === typeof obj[key];
}

class ErrorFrame {
    constructor(
        public id: string,
        public code: string,
        public type: string,
        public message: string,
    ) { }
}

class Parser {
    public validateBody(body: Buffer): { error: string, obj: unknown } {
        let jsonBody;
        try {
            jsonBody = JSON.parse(body.toString());
        } catch (err) {
            return {error: "expected body to be json", obj: null};
        }

        return {error: "", obj: jsonBody};
    }

    public parse(body: Buffer): { error: string, value?: ErrorFrame } {
        const {error, obj} = this.validateBody(body);

        if (error) {
            return {error};
        }

        if (typeof obj !== "object" || obj === null) {
            return {error: "expected json body to be object"};
        }

        if (!has(obj, "id", "string")) {
            return {error: "expected body to contain id field"};
        }
        if (!has(obj, "code", "string")) {
            return {error: "expected body to contain code field"};
        }

        if (!has(obj, "type", "string")) {
            return {error: "expected body to contain type field"};
        }

        if (!has(obj, "message", "string")) {
            return {error: "expected body to contain message field"};
        }

        return {
            error: "",
            value: new ErrorFrame(obj.id, obj.code, obj.type, obj.message),
        };
    }
}
@Validark

This comment has been minimized.

Copy link

@Validark Validark commented Jul 6, 2019

Also, this shouldn't error

const o = {
	a: 1,
	b: 2,
	c: 3,
};

function f(s: string) {
	if (s in o) {
		console.log(s, o[s]);
	}
}

f("");
f("a"); // a 1
f("b"); // b 2
f("c"); // c 3
f("d");

https://www.typescriptlang.org/play/#code/MYewdgzgLgBCMF4YG8BQBIAhgLhgRgBoMAjXAJiPWFwGYiBfAblVQDMBXMYKAS3BlYAKCLmgAnHmADmAShQYerGMJiS4ctOirgIIADYBTAHR6QU4QTgBtCAF0ZzdPVTO2ggETuHb95i-Mhd2J-H2AQwIATEKA

@iugo

This comment has been minimized.

Copy link

@iugo iugo commented Aug 22, 2019

function check1(v: unknown): v is { name: string; age: number } {
  if (typeof v !== 'object' || v === null) {
    return false;
  }
  const a = v as { name: unknown; age: unknown };
  if (typeof a.name !== 'string' || typeof a.age !== "number") {
    return false;
  }
  return true;
}

function check2(v: unknown): v is { name: string; age: number } {
  if (typeof v !== 'object' || v === null) {
    return false;
  }
  if (typeof v.name !== 'string' || typeof v.age !== "number") {
    return false;
  }
  return true;
}

https://www.typescriptlang.org/play/#code/GYVwdgxgLglg9mABBAFgUwgawIwAoBuAXIuJmHAO5gCUx+iMAzogN6JgCGAtmsY1ACcYYAOYBuRBxG92ILgCM0AxAF9WAKEQNgiXFACeABzRwd9AIQBeS4gDkceQCsMUW4gA+7xPWs2wIABsA6g0tLQE0KBABJGAOAMY0MU1VFIgEfklEG3oOZjZOHmJScioJKRkSyiQVZK0YHT0jEx0OADpCtEQrG1t+IVE3T0QDY1NJNoru60QAIn8FJVmQlhTwyOjY+MS61PWomJGBECT1FXV1UEhYBGR0LAAmAmKwMmrabwZ89m4ZfuFxJJpMQFoplGpVvVGqMWp8enYHM5oEMvD4Zv4gis1ogIgctglTlpzlDdDDxvgOr9pr1-oMPF4yWZJtJqXNQUssWEcRtDnECbtidy8UcTskVEA

@felixfbecker

This comment has been minimized.

Copy link

@felixfbecker felixfbecker commented Aug 27, 2019

This currently makes it impossible to write a type guard function that checks for properties without using casts or any. This makes type guards a lot less safe because people often forget to check edge cases (e.g. in will throw when used on undefined, or if typeof x === 'object' x can still be null). If we could declare the parameter as unknown (instead of any), it would force users to work their way through all edge cases with checks.

@deepkolos

This comment has been minimized.

Copy link

@deepkolos deepkolos commented Aug 30, 2019

in operator case of simple map

const MAP = {
  I: "info",
  D: "debug",
  W: "warn"
};

const t: string = 'I'

if (t in MAP)
  MAP[t]// error
}
@wolfd

This comment has been minimized.

Copy link

@wolfd wolfd commented Sep 12, 2019

I've found myself floundering multiple times trying to write good type guards for unknown inputs. Is there an official suggestion for what to do for now? I love the idea of an unknown type, but it seems rather incomplete without a way to safely find out if properties exist on object.

@dimabory

This comment has been minimized.

Copy link

@dimabory dimabory commented Oct 30, 2019

I've found myself floundering multiple times trying to write good type guards for unknown inputs. Is there an official suggestion for what to do for now? I love the idea of an unknown type, but it seems rather incomplete without a way to safely find out if properties exist on object.

It can be a simple solution for some cases if you know the property name (also easily extensible).

function foo(obj: unknown, byDefault?: unknown) {
    const { bar = byDefault } = { ...(typeof obj === 'object' ? obj : {}) };
    return bar;
}
@karol-majewski

This comment has been minimized.

Copy link

@karol-majewski karol-majewski commented Oct 30, 2019

I've found myself floundering multiple times trying to write good type guards for unknown inputs. Is there an official suggestion for what to do for now? I love the idea of an unknown type, but it seems rather incomplete without a way to safely find out if properties exist on object.

Here's one way. Check if the input can have arbitrary properties, and then verify their type.

interface DictionaryLike {
  [index: string]: unknown;
}

export const isDictionaryLike = (candidate: unknown): candidate is DictionaryLike =>
  typeof (candidate === 'object') && (candidate !== null);
interface Foo {
  foo: string;
}

const isFoo = (candidate: unknown): candidate is Foo =>
  isDictionaryLike(candidate) && (typeof candidate.foo === 'string');
@alaycock

This comment has been minimized.

Copy link

@alaycock alaycock commented Oct 30, 2019

Here's a workaround I found in this older issue, which seems to work sufficiently well for my use-case.

export function hasKey<K extends string>(k: K, o: {}): o is { [_ in K]: {} } {
  return typeof o === 'object' && k in o
}

Also, here's the StackOverflow question I had about this, in case anyone wants to provide a better answer

@lifeiscontent

This comment has been minimized.

Copy link

@lifeiscontent lifeiscontent commented Oct 30, 2019

@DanielRosenwasser would love to hear some followup from the TS Team, it would be really nice to have a working solution.

@DanielRosenwasser

This comment has been minimized.

Copy link
Member

@DanielRosenwasser DanielRosenwasser commented Oct 30, 2019

I'm on mobile but check the above design meeting notes when we changed the labels. We haven't been able to prioritize it recently, but are open to a PR.

@mkrause

This comment has been minimized.

Copy link

@mkrause mkrause commented Dec 6, 2019

I tried some of the solutions above but nothing really worked for me. The hasKey snippet above for example will lose all type information except the one property. I wrote the following, and it seems to work in all the cases I've tried:

const hasProp = <O extends object, K extends PropertyKey>(obj: O, propKey: K): obj is O & { [key in K]: unknown } =>
  propKey in obj;

If a property is known to be present then the type of that property is untouched, otherwise we add the property as unknown.

Playground link with some tests

@VSDekar

This comment has been minimized.

Copy link

@VSDekar VSDekar commented Dec 18, 2019

@mkrause I have tried your code, because i am looking for a solution to my problem with Proxies. Unfortunatly i got an error, but i think it's more another TS problem than a problem with your code.

const hasProp = <O extends object, K extends PropertyKey>(
  obj: O,
  propKey: K,
): obj is O & { [key in K]: unknown } => propKey in obj;

const myObj = {
  x: 'Hello',
};

const p = new Proxy(myObj, {
  get: (target, key) => {
    return hasProp(target, key) ? target[key] + ' World!' : 'nope';
  },
});

I get the following error:

Type 'symbol' cannot be used as an index type.(2538)

Currently i am working with:

const hasKey = <T extends object>(obj: T, k: keyof any): k is keyof T =>
  k in obj;

Which works in my Proxy problem. But here i have other issues that i cannot access the Property with string literals after the check.

@mkrause

This comment has been minimized.

Copy link

@mkrause mkrause commented Dec 18, 2019

@VSDekar Try the following:

const p = new Proxy(myObj, {
  get: <K extends PropertyKey>(target : object, key : K) => {
    return hasProp(target, key) ? target[key] + ' World!' : 'nope';
  },
});

Playground

Basically, at the point you call hasProp, key is of some general PropertyKey type, so extending the target with a key of this type is not very useful. But you can "postpone" the type of key using a generic, so that the check will be done at the usage site (p.x) instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
You can’t perform that action at this time.