Skip to content

feat(eslint-plugin): [require-object-type-annotations] add rule #5666

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

Conversation

OliverJAsh
Copy link
Contributor

@OliverJAsh OliverJAsh commented Sep 16, 2022

PR Checklist

Problem

I've added this section to elaborate on the problem I described in #3408.

For object literals without type annotations, many TypeScript features don't work. Contrived example:

type User = { name: string; age: number };

const makeUser = () => ({ name: 'bob', age: 123 });

If you try to rename a property inside of the object literal (or vice versa, the property inside the type definition), it won't update the name of the corresponding property in the User type definition. This is because TypeScript does not understand that this object literal relates to the User type.

For the same reason, other language server features like "go to definition" and "find references" on object properties will also not work.

When a type is used in many places, this makes navigation and refactoring of the code much more difficult.

Furthermore, this can result in bugs:

type Config = { foo: string; bar?: string };

const config = { foo: 'yay', bar: 'woo' };

If we rename bar in the Config type, not only are the usages not automatically renamed but also we don't get a type error to say that we're now setting an invalid property in config. In this situation it's likely we won't realise there is a problem, and this would probably cause a bug:

type Config = { foo: string; barNEW?: string };

// ❌ No type error!
const config = { foo: 'yay', bar: 'woo' };

Another example:

type State = { foo: string; bar: string };

declare const state: State;
const newState = { ...state, bar: 'woo' };

Rename bar in the State type and we get:

type State = { foo: string; barNEW: string };

declare const state: State;
// ❌ No type error!
const newState = { ...state, bar: 'woo' };

See #3408 for a real world example of this.

Note on debugging

VS Code makes it easy to identify when TypeScript understands the relationship between a value and its type because it will highlight all occurrences of a symbol when that symbol is selected:

All occurences of myString are highlighted:

Only one occurence of name is highlighted:

@nx-cloud
Copy link

nx-cloud bot commented Sep 16, 2022

☁️ Nx Cloud Report

CI is running/has finished running commands for commit dd7c54a. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this branch


🟥 Failed Commands
Node 18 - nx test @typescript-eslint/eslint-plugin
✅ Successfully ran 45 targets

Sent with 💌 from NxCloud.

@typescript-eslint
Copy link
Contributor

Thanks for the PR, @OliverJAsh!

typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community.

The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately.

Thanks again!


🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint. As a thank you, your profile/company logo will be added to our main README which receives thousands of unique visitors per day.

@netlify
Copy link

netlify bot commented Sep 16, 2022

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit dd7c54a
🔍 Latest deploy log https://app.netlify.com/sites/typescript-eslint/deploys/63288adf5ba16e000867a435
😎 Deploy Preview https://deploy-preview-5666--typescript-eslint.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site settings.

@OliverJAsh OliverJAsh force-pushed the require-object-type-annotations branch 4 times, most recently from 5db0d5b to 3d1a2ff Compare September 16, 2022 21:39
const type = checker.getTypeAtLocation(tsNode);

// Allow empty objects
if (type.getProperties().length === 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that most types will have properties!
I believe that only the following types will not have any properties:

  • null
  • undefined
  • object
  • {}

It would probably be a good idea to check the type.flags for things like ts.TypeFlags.StringLike, ts.TypeFlags.NumberLike, etc to help weed out things we definitely want to ignore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have an ObjectExpression node where the type is StringLike or NumberLike? 🤔

For context, I was only intending to catch {} with this branch.

contextualType === undefined ||
// Needed to catch object passed as a function argument where the parameter type is generic,
// e.g. an identity function
contextualType?.getSymbol()?.name === '__object'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could do this with

(contextualType.flags & ts.TypeFlags.Intrinsic) === 0 &&
checker.typeToString(contextualType) === 'object'

Which is a little less abstract than using the __ string

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, thanks! I will have to try that and see if my tests still pass.

It seems like Intrinsic doesn't exist? Also, what does "intrinsic" mean exactly?

image

@bradzacher bradzacher added the enhancement: new plugin rule New rule request for eslint-plugin label Sep 17, 2022
@bradzacher bradzacher changed the title Add new rule: require-object-type-annotations feat(eslint-plugin): [require-object-type-annotations] add rule Sep 17, 2022
@OliverJAsh OliverJAsh force-pushed the require-object-type-annotations branch from 3d1a2ff to 3935c66 Compare September 17, 2022 16:11
const checker = parserServices.program.getTypeChecker();

return {
ObjectExpression: (esNode): void => {
Copy link
Contributor Author

@OliverJAsh OliverJAsh Sep 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's one thing I need help with:

I have this test case which currently produces 2 errors:

const xs = { ys: [{ prop: 1 }] };

This makes sense because we have two ObjectExpressions that have no contextual type.

However, I think it would be better if we only reported one error for the outermost ObjectExpression, seeing as the inner object would most likely have a contextual type as soon as we provide one to the outermost object.

I thought this might be possible if I could tell ESLint to stop traversing, but it seems like that's not possible: #2734. With that in mind, is there any other way we could do this? Perhaps there's another rule that is doing something similar which I can take a look at.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depends what the goal is for the inner object.
what is the plan for the user to "solve" this inner object?
with an as assertion? (I hope not!)
as that's the only solution - then you probably want to just ignore it entirely.

see my top level comment

Copy link
Contributor Author

@OliverJAsh OliverJAsh Sep 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with an as assertion? (I hope not!)

Certainly not 😛

what is the plan for the user to "solve" this inner object?

Once they annotate the outer object, the inner object will have a contextual type, meaning that language server features will work for properties on both the inside and outside objects i.e. "rename" / "go to definition" / "find references"). See the video below where where I've tried to demonstrate this.

As I described in #3408, the goal of this rule is to make sure TypeScript understands which type an object relates to, so that tools such as "rename" / "go to definition" / "find references" on object properties work properly and to avoid bugs. I think this can only be done by reading type information.

Screen.Recording.2022-09-19.at.08.29.56.mov

Copy link
Contributor Author

@OliverJAsh OliverJAsh Sep 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are some test cases for me to consider. Saving here so I don't lose it!

{
    declare const fn: <T>(t: T) => T;
    // 2 errors
    const x = { prop: fn({ prop: 1 }) };
}
{
    declare const fn: <T>(t: T) => T;
    // 1 error
    const x: { prop: { prop: number } } = { prop: fn({ prop: 1 }) };
}
{
    declare const fn: (t: { prop: number }) => unknown;
    // 1 error
    const x = { prop: fn({ prop: 1 }) };
}
{
    // 1 error
    const x = { prop: { prop: 1 } };
}
{
    // 1 error
    const xs = { ys: [{ prop: 1 }] };
}
{
    // 2 errors
    const x = {
        prop: () => {
            const y = { prop: 1 };
            return y;
        },
    };
}
{
    // 1 error
    const x = {
        prop: () => ({ prop: 1 }),
    };
}

schema: [],
type: 'problem',
},
defaultOptions: [],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continuing the thread from here: https://twitter.com/OliverJAsh/status/1570709040765997056

Sometimes we deliberately pass an object into a generic function and we don't want to annotate that object. io-ts example:

const T = t.type({ prop: t.number });

I want to add an option whereby users can define a whitelist of function names in which the error will not trigger.

The function name whitelisted by the user could be a member expression or it could just be an identifier:

ignoreFunctions: [
  // Member expression
  't.type',
  // Identifier
  'foo'
]

I initially tried to do this using whitelist.includes(callee.getText()) but this of course breaks in complex cases such as:

const T = t
  // comment  
  .type({ prop: t.number });

I'm not sure how to check whether the callee matches the whitelist—especially given we don't know whether the whitelisted function is a member expression (e.g. t.type) or just an identifier (e.g. foo).

Alternatively the rule option could ask for ESQuery selectors and then we check whether the selector matches the callee.

excludeCallees: [
  'MemberExpression[object.name="t"][property.name="type"]',
  'Identifier[name="foo"]'
]

… but I'm not sure how to check whether a node matches a selector? Are there any examples of this in other rules?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#5271 is the current RFC for describing a standardized way to do that. It's tricky; we wouldn't want to allowlist all things named t by accident.

Copy link
Member

@bradzacher bradzacher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right now your rule is essentially saying "if object has no contextual type, report".
you are also using type information.

But I think you can achieve this in a different way - purely syntactically.

Specifically you're looking for these cases:

const variable = { object };
const variable = callExpr({ object });
const variable = call.expr({ object });

Which is entirely matchable via the AST!
Doing a pure AST match here means that (a) your rule will be faster and (b) you can match exact cases.

One thing to think about is this:

const variable = [{ object }];

Is this something the rule should handle?

const checker = parserServices.program.getTypeChecker();

return {
ObjectExpression: (esNode): void => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depends what the goal is for the inner object.
what is the plan for the user to "solve" this inner object?
with an as assertion? (I hope not!)
as that's the only solution - then you probably want to just ignore it entirely.

see my top level comment

@OliverJAsh
Copy link
Contributor Author

OliverJAsh commented Sep 19, 2022

@bradzacher

Specifically you're looking for these cases:

I think there are a few more cases to consider. I tried to demonstrate these in my tests. For example, function return types:

const fn = () => ({ prop: 1 });

Which is entirely matchable via the AST!

In that case how would you distinguish between these two cases that I've defined in the tests?

Invalid:

declare const f: <T>(x: T) => T;
f({ prop: 1 });

Valid:

declare const f: (x: { prop: number }) => unknown;
f({ prop: 1 });

Under this rule, any object that has a contextual type is valid, because a contextual type means that language server features will work (i.e. "rename" / "go to definition" / "find references" on object properties). Essentially we're just trying to make sure that the object is linked to a type somewhere.

Screen.Recording.2022-09-19.at.08.48.57.mov

One thing to think about is this:

const variable = [{ object }];

I think this is already covered by my tests:

Invalid:

const xs = [{ prop: 1 }];

Valid:

const xs: Array<{ prop: number }> = [{ prop: 1 }];

@OliverJAsh OliverJAsh requested review from JoshuaKGoldberg and bradzacher and removed request for JoshuaKGoldberg September 19, 2022 07:34
@bradzacher
Copy link
Member

Okay so getting back to this - I definitely like the idea behind this.
I do think that we really need to be careful about how this works though as there's a lot of room for false positives if we don't get things right.

I think we're going to want to ensure we ignore all mapped types {[k in string]: v}, {k in 'union']: v} and types with index signatures {[k: string]: v}.

How should the rule handle any, object, {} and unknown - should it ignore them? I think so.

I think really we need to define the set of things we want to match against - which is just where an object falls into an anonymous object type, right?


The easy way to implement this will be to just get the contextual type of every object expression. Though it will be quite slow to go that route, I believe.
Idiomatic JS uses a lot of object literals, and if we have to look up the contextual type for every single one of those then we're going to burn all the cycles.

It's hard to say for sure though. Really need to test at scale on a "real" codebase.
Suggestion: implement the rule with just types, then perf test it with TIMING=all yarn eslint on a real codebase. If it's up there and looks to be a slow rule, then we can go the route of building in some static AST checks to circumvent the need for types.

Some perf optimisations

Cases we know for sure we can statically detect and ignore:

// variables
const a1: ObjType = { ... };

// functions with defined return types ***that aren't generics***
function b1(): ObjType { return { ... } }
const b2 = function (): ObjType { return { ... } }
const b3 = (): ObjType => ({ ... });
const b4 = (): ObjType => { return { ... } };

// function parameters with a default value AND a type ***that aren't generics***
function c(arg: ObjType = { ... }) {}

// assertions
const d1 = { ... } as ObjType;
const d2 = <ObjType>{ ... };
const d3 = { ... } satisfies ObjType;

// class props with types
class E {
  e: ObjType = { ... };
}

Cases we know are always unsafe, without needing types

// un-annotated objects -> no need to use types to check
const a1 = { ... };

// this sort of case is bad because they'll lead to anonymous types, regardless of what constraints exist on the generic
const b1 = <T,>(): T => ({ ... });

class C1 {
  // same as untyped variable
  c1 = { ... };
} 
// one caveat that *might be safe* - need use types for
class C2 extends C3 {
  c1 = { ... }; // might be typed on the parent class
}
class C4 implements C5 {
  c1 = { ... }; // might be typed on the interface
}

// untyped parameter defaults on a function declaration
function d1(arg = { ... }) {}
// but function expressions are hairy as you need to know if they are contextually typed - so should always check these
const d2: (arg: ObjType) => void = function d2(arg = { ... }) {}
const d3: (arg: ObjType) => void = (arg = { ... }) => {}

Probably more cases we can enumerate if needs be.

@OliverJAsh
Copy link
Contributor Author

implement the rule with just types, then perf test it with TIMING=all yarn eslint on a real codebase.

We've been using this lint rule in production at Unsplash for about 6 months. Here's the data for the Unsplash codebase:

Rule                                             | Time (ms) | Relative
:------------------------------------------------|----------:|--------:
local/require-object-type-annotations            |  8621.609 |    60.2%
react/prefer-stateless-function                  |  1412.163 |     9.9%
@typescript-eslint/strict-boolean-expressions    |  1099.868 |     7.7%

As expected, the performance is far from ideal, but it's a price we're more than happy to pay.

Perhaps you have some ideas about how we could improve the performance of this version. I can run further tests if that helps!

Note the version we're using at Unsplash is a few steps ahead of the version in this PR: https://gist.github.com/OliverJAsh/ec82f0bfe9410b36dcf64a7000b713f6.

How should the rule handle any, object, {} and unknown - should it ignore them? I think so.

Agreed. I think the tests in my gist cover this.

I think really we need to define the set of things we want to match against - which is just where an object falls into an anonymous object type, right?

The invalid tests in my gist cover all of the cases I could think of. I don't have any tests for objects inside classes because we only use functions at Unsplash, so that's something you might want to add.


I worry that without type information this lint rule would have too many false negatives, but there's a good chance I'm wrong and I'm interested to see how it goes. Unfortunately I don't think I'm going to have much time to help with this though—especially considering we've effectively solved the problem at Unsplash and we're perfectly happy with the performance trade-off.

@JoshuaKGoldberg
Copy link
Member

This PR has been stale for quite a while now. Although this is an interesting one with lots of potential, closing out inactive ones helps keep the queue clean. Closing just for housekeeping purposes.

If anybody picks up where this PR left off and uses code from it, please do add co-author credit.

Cheers!

@JoshuaKGoldberg JoshuaKGoldberg added the stale PRs or Issues that are at risk of being or have been closed due to inactivity for a prolonged period label Jul 16, 2023
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jul 24, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement: new plugin rule New rule request for eslint-plugin stale PRs or Issues that are at risk of being or have been closed due to inactivity for a prolonged period
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants