diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index 809aac848a60..0f0f442be57b 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -191,6 +191,7 @@ In these cases, we create what we call an extension rule; a rule within our plug
| [`@typescript-eslint/no-unused-vars`](./docs/rules/no-unused-vars.md) | Disallow unused variables | :heavy_check_mark: | | |
| [`@typescript-eslint/no-use-before-define`](./docs/rules/no-use-before-define.md) | Disallow the use of variables before they are defined | :heavy_check_mark: | | |
| [`@typescript-eslint/no-useless-constructor`](./docs/rules/no-useless-constructor.md) | Disallow unnecessary constructors | | | |
+| [`@typescript-eslint/object-curly-spacing`](./docs/rules/object-curly-spacing.md) | Enforce consistent spacing inside braces | | :wrench: | |
| [`@typescript-eslint/quotes`](./docs/rules/quotes.md) | Enforce the consistent use of either backticks, double, or single quotes | | :wrench: | |
| [`@typescript-eslint/require-await`](./docs/rules/require-await.md) | Disallow async functions which have no `await` expression | :heavy_check_mark: | | :thought_balloon: |
| [`@typescript-eslint/return-await`](./docs/rules/return-await.md) | Enforces consistent returning of awaited values | | :wrench: | :thought_balloon: |
diff --git a/packages/eslint-plugin/docs/rules/object-curly-spacing.md b/packages/eslint-plugin/docs/rules/object-curly-spacing.md
new file mode 100644
index 000000000000..f64b04dc6f5e
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/object-curly-spacing.md
@@ -0,0 +1,22 @@
+# Enforce consistent spacing inside braces (`object-curly-spacing`)
+
+## Rule Details
+
+This rule extends the base [`eslint/object-curly-spacing`](https://eslint.org/docs/rules/object-curly-spacing) rule.
+It supports all options and features of the base rule.
+
+## How to use
+
+```cjson
+{
+ // note you must disable the base rule as it can report incorrect errors
+ "object-curly-spacing": "off",
+ "@typescript-eslint/object-curly-spacing": ["error"]
+}
+```
+
+## Options
+
+See [`eslint/object-curly-spacing` options](https://eslint.org/docs/rules/object-curly-spacing#options).
+
+Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/object-curly-spacing.md)
diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json
index 85cf2e1ecc61..f67054d673f5 100644
--- a/packages/eslint-plugin/src/configs/all.json
+++ b/packages/eslint-plugin/src/configs/all.json
@@ -77,6 +77,8 @@
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-var-requires": "error",
+ "@typescript-eslint/object-curly-spacing": "error",
+ "object-curly-spacing": "off",
"@typescript-eslint/prefer-as-const": "error",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts
index 15ce8dd1a820..9c89f8ef5713 100644
--- a/packages/eslint-plugin/src/rules/index.ts
+++ b/packages/eslint-plugin/src/rules/index.ts
@@ -66,6 +66,7 @@ import noUnusedVarsExperimental from './no-unused-vars-experimental';
import noUseBeforeDefine from './no-use-before-define';
import noUselessConstructor from './no-useless-constructor';
import noVarRequires from './no-var-requires';
+import objectCurlySpacing from './object-curly-spacing';
import preferAsConst from './prefer-as-const';
import preferForOf from './prefer-for-of';
import preferFunctionType from './prefer-function-type';
@@ -163,6 +164,7 @@ export default {
'no-use-before-define': noUseBeforeDefine,
'no-useless-constructor': noUselessConstructor,
'no-var-requires': noVarRequires,
+ 'object-curly-spacing': objectCurlySpacing,
'prefer-as-const': preferAsConst,
'prefer-for-of': preferForOf,
'prefer-function-type': preferFunctionType,
diff --git a/packages/eslint-plugin/src/rules/object-curly-spacing.ts b/packages/eslint-plugin/src/rules/object-curly-spacing.ts
new file mode 100644
index 000000000000..44dcd89c41f4
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/object-curly-spacing.ts
@@ -0,0 +1,273 @@
+import {
+ AST_NODE_TYPES,
+ AST_TOKEN_TYPES,
+ TSESTree,
+} from '@typescript-eslint/experimental-utils';
+import baseRule from 'eslint/lib/rules/object-curly-spacing';
+import {
+ createRule,
+ InferMessageIdsTypeFromRule,
+ InferOptionsTypeFromRule,
+ isClosingBraceToken,
+ isClosingBracketToken,
+ isTokenOnSameLine,
+} from '../util';
+
+export type Options = InferOptionsTypeFromRule;
+export type MessageIds = InferMessageIdsTypeFromRule;
+
+export default createRule({
+ name: 'object-curly-spacing',
+ meta: {
+ type: 'layout',
+ docs: {
+ description: 'Enforce consistent spacing inside braces',
+ category: 'Stylistic Issues',
+ recommended: false,
+ extendsBaseRule: true,
+ },
+ messages: {
+ requireSpaceBefore: "A space is required before '{{token}}'.",
+ requireSpaceAfter: "A space is required after '{{token}}'.",
+ unexpectedSpaceBefore: "There should be no space before '{{token}}'.",
+ unexpectedSpaceAfter: "There should be no space after '{{token}}'.",
+ },
+ fixable: baseRule.meta.fixable,
+ schema: baseRule.meta.schema,
+ },
+ defaultOptions: ['never'],
+ create(context) {
+ const spaced = context.options[0] === 'always',
+ sourceCode = context.getSourceCode();
+
+ /**
+ * Determines whether an option is set, relative to the spacing option.
+ * If spaced is "always", then check whether option is set to false.
+ * If spaced is "never", then check whether option is set to true.
+ * @param {Object} option The option to exclude.
+ * @returns {boolean} Whether or not the property is excluded.
+ */
+ function isOptionSet(
+ option: 'arraysInObjects' | 'objectsInObjects',
+ ): boolean {
+ return context.options[1]
+ ? context.options[1][option] === !spaced
+ : false;
+ }
+
+ const options = {
+ spaced,
+ arraysInObjectsException: isOptionSet('arraysInObjects'),
+ objectsInObjectsException: isOptionSet('objectsInObjects'),
+ };
+
+ //--------------------------------------------------------------------------
+ // Helpers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reports that there shouldn't be a space after the first token
+ * @param node The node to report in the event of an error.
+ * @param token The token to use for the report.
+ */
+ function reportNoBeginningSpace(
+ node: TSESTree.TSTypeLiteral,
+ token: TSESTree.Token,
+ ): void {
+ const nextToken = context
+ .getSourceCode()
+ .getTokenAfter(token, { includeComments: true })!;
+
+ context.report({
+ node,
+ loc: { start: token.loc.end, end: nextToken.loc.start },
+ messageId: 'unexpectedSpaceAfter',
+ data: {
+ token: token.value,
+ },
+ fix(fixer) {
+ return fixer.removeRange([token.range[1], nextToken.range[0]]);
+ },
+ });
+ }
+
+ /**
+ * Reports that there shouldn't be a space before the last token
+ * @param node The node to report in the event of an error.
+ * @param token The token to use for the report.
+ */
+ function reportNoEndingSpace(
+ node: TSESTree.TSTypeLiteral,
+ token: TSESTree.Token,
+ ): void {
+ const previousToken = context
+ .getSourceCode()
+ .getTokenBefore(token, { includeComments: true })!;
+
+ context.report({
+ node,
+ loc: { start: previousToken.loc.end, end: token.loc.start },
+ messageId: 'unexpectedSpaceBefore',
+ data: {
+ token: token.value,
+ },
+ fix(fixer) {
+ return fixer.removeRange([previousToken.range[1], token.range[0]]);
+ },
+ });
+ }
+
+ /**
+ * Reports that there should be a space after the first token
+ * @param node The node to report in the event of an error.
+ * @param token The token to use for the report.
+ */
+ function reportRequiredBeginningSpace(
+ node: TSESTree.TSTypeLiteral,
+ token: TSESTree.Token,
+ ): void {
+ context.report({
+ node,
+ loc: token.loc,
+ messageId: 'requireSpaceAfter',
+ data: {
+ token: token.value,
+ },
+ fix(fixer) {
+ return fixer.insertTextAfter(token, ' ');
+ },
+ });
+ }
+
+ /**
+ * Reports that there should be a space before the last token
+ * @param node The node to report in the event of an error.
+ * @param token The token to use for the report.
+ */
+ function reportRequiredEndingSpace(
+ node: TSESTree.TSTypeLiteral,
+ token: TSESTree.Token,
+ ): void {
+ context.report({
+ node,
+ loc: token.loc,
+ messageId: 'requireSpaceBefore',
+ data: {
+ token: token.value,
+ },
+ fix(fixer) {
+ return fixer.insertTextBefore(token, ' ');
+ },
+ });
+ }
+
+ /**
+ * Determines if spacing in curly braces is valid.
+ * @param node The AST node to check.
+ * @param first The first token to check (should be the opening brace)
+ * @param second The second token to check (should be first after the opening brace)
+ * @param penultimate The penultimate token to check (should be last before closing brace)
+ * @param last The last token to check (should be closing brace)
+ */
+ function validateBraceSpacing(
+ node: TSESTree.TSTypeLiteral,
+ first: TSESTree.Token,
+ second: TSESTree.Token | TSESTree.Comment,
+ penultimate: TSESTree.Token | TSESTree.Comment,
+ last: TSESTree.Token,
+ ): void {
+ if (isTokenOnSameLine(first, second)) {
+ const firstSpaced = sourceCode.isSpaceBetween(first, second);
+ const secondType = sourceCode.getNodeByRangeIndex(second.range[0])!
+ .type;
+
+ const openingCurlyBraceMustBeSpaced =
+ options.arraysInObjectsException &&
+ secondType === AST_NODE_TYPES.TSIndexSignature
+ ? !options.spaced
+ : options.spaced;
+
+ if (openingCurlyBraceMustBeSpaced && !firstSpaced) {
+ reportRequiredBeginningSpace(node, first);
+ }
+ if (
+ !openingCurlyBraceMustBeSpaced &&
+ firstSpaced &&
+ second.type !== AST_TOKEN_TYPES.Line
+ ) {
+ reportNoBeginningSpace(node, first);
+ }
+ }
+
+ if (isTokenOnSameLine(penultimate, last)) {
+ const shouldCheckPenultimate =
+ (options.arraysInObjectsException &&
+ isClosingBracketToken(penultimate)) ||
+ (options.objectsInObjectsException &&
+ isClosingBraceToken(penultimate));
+ const penultimateType =
+ shouldCheckPenultimate &&
+ sourceCode.getNodeByRangeIndex(penultimate.range[0])!.type;
+
+ const closingCurlyBraceMustBeSpaced =
+ (options.arraysInObjectsException &&
+ penultimateType === AST_NODE_TYPES.TSTupleType) ||
+ (options.objectsInObjectsException &&
+ penultimateType === AST_NODE_TYPES.TSTypeLiteral)
+ ? !options.spaced
+ : options.spaced;
+
+ const lastSpaced = sourceCode.isSpaceBetween(penultimate, last);
+
+ if (closingCurlyBraceMustBeSpaced && !lastSpaced) {
+ reportRequiredEndingSpace(node, last);
+ }
+ if (!closingCurlyBraceMustBeSpaced && lastSpaced) {
+ reportNoEndingSpace(node, last);
+ }
+ }
+ }
+
+ /**
+ * Gets '}' token of an object node.
+ *
+ * Because the last token of object patterns might be a type annotation,
+ * this traverses tokens preceded by the last property, then returns the
+ * first '}' token.
+ * @param node The node to get. This node is an
+ * ObjectExpression or an ObjectPattern. And this node has one or
+ * more properties.
+ * @returns '}' token.
+ */
+ function getClosingBraceOfObject(
+ node: TSESTree.TSTypeLiteral,
+ ): TSESTree.Token | null {
+ const lastProperty = node.members[node.members.length - 1];
+
+ return sourceCode.getTokenAfter(lastProperty, isClosingBraceToken);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ const rules = baseRule.create(context);
+ return {
+ ...rules,
+ TSTypeLiteral(node: TSESTree.TSTypeLiteral): void {
+ if (node.members.length === 0) {
+ return;
+ }
+
+ const first = sourceCode.getFirstToken(node)!,
+ last = getClosingBraceOfObject(node)!,
+ second = sourceCode.getTokenAfter(first, { includeComments: true })!,
+ penultimate = sourceCode.getTokenBefore(last, {
+ includeComments: true,
+ })!;
+
+ validateBraceSpacing(node, first, second, penultimate, last);
+ },
+ };
+ },
+});
diff --git a/packages/eslint-plugin/tests/rules/object-curly-spacing.test.ts b/packages/eslint-plugin/tests/rules/object-curly-spacing.test.ts
new file mode 100644
index 000000000000..610a34f86917
--- /dev/null
+++ b/packages/eslint-plugin/tests/rules/object-curly-spacing.test.ts
@@ -0,0 +1,199 @@
+/* eslint-disable eslint-comments/no-use */
+// this rule tests the position of braces, which prettier will want to fix and break the tests
+/* eslint "@typescript-eslint/internal/plugin-test-formatting": ["error", { formatWithPrettier: false }] */
+/* eslint-enable eslint-comments/no-use */
+
+import rule from '../../src/rules/object-curly-spacing';
+import { RuleTester } from '../RuleTester';
+
+const ruleTester = new RuleTester({
+ parser: '@typescript-eslint/parser',
+});
+
+ruleTester.run('object-curly-spacing', rule, {
+ valid: [
+ {
+ code: 'const x:{}',
+ },
+ {
+ code: 'const x:{ }',
+ },
+ {
+ code: 'const x:{f: number}',
+ },
+ {
+ code: 'const x:{ // line-comment\nf: number\n}',
+ },
+ {
+ code: 'const x:{// line-comment\nf: number\n}',
+ },
+ {
+ code: 'const x:{/* inline-comment */f: number/* inline-comment */}',
+ },
+ {
+ code: 'const x:{\nf: number\n}',
+ },
+ {
+ code: 'const x:{f: {g: number}}',
+ },
+ {
+ code: 'const x:{f: [number]}',
+ },
+ {
+ code: 'const x:{[key: string]: value}',
+ },
+ {
+ code: 'const x:{[key: string]: [number]}',
+ },
+ {
+ code: 'const x:{f: {g: number} }',
+ options: ['never', { objectsInObjects: true }],
+ },
+ {
+ code: 'const x:{f: {g: number}}',
+ options: ['never', { objectsInObjects: false }],
+ },
+ {
+ code: 'const x:{f: () => {g: number} }',
+ options: ['never', { objectsInObjects: true }],
+ },
+ {
+ code: 'const x:{f: () => {g: number}}',
+ options: ['never', { objectsInObjects: false }],
+ },
+ {
+ code: 'const x:{f: [number] }',
+ options: ['never', { arraysInObjects: true }],
+ },
+ {
+ code: 'const x:{f: [ number ]}',
+ options: ['never', { arraysInObjects: false }],
+ },
+ {
+ code: 'const x:{ [key: string]: value}',
+ options: ['never', { arraysInObjects: true }],
+ },
+ {
+ code: 'const x:{[key: string]: value}',
+ options: ['never', { arraysInObjects: false }],
+ },
+ {
+ code: 'const x:{ [key: string]: [number] }',
+ options: ['never', { arraysInObjects: true }],
+ },
+ {
+ code: 'const x:{[key: string]: [number]}',
+ options: ['never', { arraysInObjects: false }],
+ },
+ {
+ code: 'const x:{}',
+ options: ['always'],
+ },
+ {
+ code: 'const x:{ }',
+ options: ['always'],
+ },
+ {
+ code: 'const x:{ f: number }',
+ options: ['always'],
+ },
+ {
+ code: 'const x:{ // line-comment\nf: number\n}',
+ options: ['always'],
+ },
+ {
+ code: 'const x:{ /* inline-comment */ f: number /* inline-comment */ }',
+ options: ['always'],
+ },
+ {
+ code: 'const x:{\nf: number\n}',
+ options: ['always'],
+ },
+ {
+ code: 'const x:{ f: [number] }',
+ options: ['always'],
+ },
+ {
+ code: 'const x:{ f: { g: number } }',
+ options: ['always', { objectsInObjects: true }],
+ },
+ {
+ code: 'const x:{ f: { g: number }}',
+ options: ['always', { objectsInObjects: false }],
+ },
+ {
+ code: 'const x:{ f: () => { g: number } }',
+ options: ['always', { objectsInObjects: true }],
+ },
+ {
+ code: 'const x:{ f: () => { g: number }}',
+ options: ['always', { objectsInObjects: false }],
+ },
+ {
+ code: 'const x:{ f: [number] }',
+ options: ['always', { arraysInObjects: true }],
+ },
+ {
+ code: 'const x:{ f: [ number ]}',
+ options: ['always', { arraysInObjects: false }],
+ },
+ {
+ code: 'const x:{ [key: string]: value }',
+ options: ['always', { arraysInObjects: true }],
+ },
+ {
+ code: 'const x:{[key: string]: value }',
+ options: ['always', { arraysInObjects: false }],
+ },
+ {
+ code: 'const x:{ [key: string]: [number] }',
+ options: ['always', { arraysInObjects: true }],
+ },
+ {
+ code: 'const x:{[key: string]: [number]}',
+ options: ['always', { arraysInObjects: false }],
+ },
+ ],
+
+ invalid: [
+ {
+ code: 'type x = { f: number }',
+ output: 'type x = {f: number}',
+ errors: [
+ { messageId: 'unexpectedSpaceAfter' },
+ { messageId: 'unexpectedSpaceBefore' },
+ ],
+ },
+ {
+ code: 'type x = { f: number}',
+ output: 'type x = {f: number}',
+ errors: [{ messageId: 'unexpectedSpaceAfter' }],
+ },
+ {
+ code: 'type x = {f: number }',
+ output: 'type x = {f: number}',
+ errors: [{ messageId: 'unexpectedSpaceBefore' }],
+ },
+ {
+ code: 'type x = {f: number}',
+ output: 'type x = { f: number }',
+ options: ['always'],
+ errors: [
+ { messageId: 'requireSpaceAfter' },
+ { messageId: 'requireSpaceBefore' },
+ ],
+ },
+ {
+ code: 'type x = {f: number }',
+ output: 'type x = { f: number }',
+ options: ['always'],
+ errors: [{ messageId: 'requireSpaceAfter' }],
+ },
+ {
+ code: 'type x = { f: number}',
+ output: 'type x = { f: number }',
+ options: ['always'],
+ errors: [{ messageId: 'requireSpaceBefore' }],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts
index ea60d9b31697..4cb7efdc57a8 100644
--- a/packages/eslint-plugin/typings/eslint-rules.d.ts
+++ b/packages/eslint-plugin/typings/eslint-rules.d.ts
@@ -543,3 +543,28 @@ declare module 'eslint/lib/rules/no-extra-semi' {
>;
export = rule;
}
+
+declare module 'eslint/lib/rules/object-curly-spacing' {
+ import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
+
+ const rule: TSESLint.RuleModule<
+ | 'requireSpaceBefore'
+ | 'requireSpaceAfter'
+ | 'unexpectedSpaceBefore'
+ | 'unexpectedSpaceAfter',
+ [
+ 'always' | 'never',
+ {
+ arraysInObjects?: boolean;
+ objectsInObjects?: boolean;
+ }?,
+ ],
+ {
+ ObjectPattern(node: TSESTree.ObjectPattern): void;
+ ObjectExpression(node: TSESTree.ObjectExpression): void;
+ ImportDeclaration(node: TSESTree.ImportDeclaration): void;
+ ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration): void;
+ }
+ >;
+ export = rule;
+}