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

WordCount: Add types #22077

Merged
merged 22 commits into from
Oct 19, 2020
Merged

WordCount: Add types #22077

merged 22 commits into from
Oct 19, 2020

Conversation

nb
Copy link
Member

@nb nb commented May 4, 2020

Part of #18838.

Add types to wordcount package.

Testing Instructions

  1. Run npm run build:package-types –– shouldn't be any errors.
  2. Try it in the UI – open the sample post and it should say something around 717 words. And start a new post and it should count words properly.

I am quite new to TypeScript and adding types to Gutenberg, so for the sake of learning, bring all of your nitpicking :-)

@nb nb added [Type] Code Quality Issues or PRs that relate to code quality [Package] Word count /packages/wordcount labels May 4, 2020
@nb nb requested a review from aduth as a code owner May 4, 2020 12:42
@nb nb requested a review from gziolo May 4, 2020 12:45
@github-actions
Copy link

github-actions bot commented May 4, 2020

Size Change: -3.63 kB (0%)

Total Size: 1.19 MB

Filename Size Change
build/a11y/index.js 1.14 kB +1 B
build/annotations/index.js 3.54 kB +22 B (0%)
build/autop/index.js 2.72 kB -1 B
build/blob/index.js 668 B +1 B
build/block-directory/index.js 8.6 kB +38 B (0%)
build/block-editor/index.js 130 kB +40 B (0%)
build/block-editor/style-rtl.css 11 kB +13 B (0%)
build/block-editor/style.css 10.9 kB +13 B (0%)
build/block-library/editor-rtl.css 8.69 kB +43 B (0%)
build/block-library/editor.css 8.69 kB +45 B (0%)
build/block-library/index.js 142 kB -2.67 kB (1%)
build/block-library/style-rtl.css 7.71 kB +14 B (0%)
build/block-library/style.css 7.71 kB +15 B (0%)
build/block-serialization-default-parser/index.js 1.77 kB -3 B (0%)
build/blocks/index.js 47.6 kB +27 B (0%)
build/components/index.js 169 kB +392 B (0%)
build/components/style-rtl.css 15.4 kB -44 B (0%)
build/components/style.css 15.4 kB -43 B (0%)
build/compose/index.js 9.63 kB -6 B (0%)
build/core-data/index.js 12.1 kB +39 B (0%)
build/data-controls/index.js 684 B -1 B
build/data/index.js 8.63 kB -5 B (0%)
build/date/index.js 31.9 kB -3 B (0%)
build/dom-ready/index.js 569 B +1 B
build/dom/index.js 4.43 kB +2 B (0%)
build/edit-navigation/index.js 10.6 kB -3 B (0%)
build/edit-post/index.js 306 kB +309 B (0%)
build/edit-post/style-rtl.css 6.37 kB +76 B (1%)
build/edit-post/style.css 6.35 kB +74 B (1%)
build/edit-site/index.js 21.6 kB +314 B (1%)
build/edit-site/style-rtl.css 3.8 kB -45 B (1%)
build/edit-site/style.css 3.81 kB -45 B (1%)
build/edit-widgets/index.js 21.6 kB +289 B (1%)
build/edit-widgets/style-rtl.css 3.09 kB +117 B (3%)
build/edit-widgets/style.css 3.09 kB +117 B (3%)
build/editor/index.js 42.6 kB -2.85 kB (6%)
build/editor/style-rtl.css 3.85 kB +4 B (0%)
build/editor/style.css 3.84 kB +1 B
build/element/index.js 4.45 kB +1 B
build/escape-html/index.js 733 B -1 B
build/i18n/index.js 3.54 kB +1 B
build/is-shallow-equal/index.js 709 B -1 B
build/keyboard-shortcuts/index.js 2.39 kB -1 B
build/list-reusable-blocks/index.js 3.02 kB +2 B (0%)
build/media-utils/index.js 5.12 kB -1 B
build/notices/index.js 1.69 kB +2 B (0%)
build/nux/index.js 3.27 kB +6 B (0%)
build/plugins/index.js 2.44 kB +1 B
build/primitives/index.js 1.35 kB +12 B (0%)
build/priority-queue/index.js 789 B -1 B
build/redux-routine/index.js 2.85 kB +3 B (0%)
build/rich-text/index.js 13 kB +5 B (0%)
build/shortcode/index.js 1.7 kB +1 B
build/url/index.js 4.07 kB +3 B (0%)
build/viewport/index.js 1.75 kB -1 B
build/wordcount/index.js 1.23 kB +53 B (4%)
ℹ️ View Unchanged
Filename Size Change
build/api-fetch/index.js 3.35 kB 0 B
build/block-directory/style-rtl.css 943 B 0 B
build/block-directory/style.css 942 B 0 B
build/block-library/theme-rtl.css 741 B 0 B
build/block-library/theme.css 741 B 0 B
build/block-serialization-spec-parser/index.js 3.1 kB 0 B
build/deprecated/index.js 772 B 0 B
build/edit-navigation/style-rtl.css 868 B 0 B
build/edit-navigation/style.css 871 B 0 B
build/editor/editor-styles-rtl.css 480 B 0 B
build/editor/editor-styles.css 482 B 0 B
build/format-library/index.js 7.49 kB 0 B
build/format-library/style-rtl.css 547 B 0 B
build/format-library/style.css 548 B 0 B
build/hooks/index.js 1.74 kB 0 B
build/html-entities/index.js 622 B 0 B
build/keycodes/index.js 1.85 kB 0 B
build/list-reusable-blocks/style-rtl.css 476 B 0 B
build/list-reusable-blocks/style.css 476 B 0 B
build/nux/style-rtl.css 671 B 0 B
build/nux/style.css 668 B 0 B
build/reusable-blocks/index.js 3.04 kB 0 B
build/server-side-render/index.js 2.6 kB 0 B
build/token-list/index.js 1.24 kB 0 B
build/warning/index.js 1.13 kB 0 B

compressed-size-action

packages/wordcount/src/defaultSettings.js Outdated Show resolved Hide resolved
packages/wordcount/src/defaultSettings.js Outdated Show resolved Hide resolved
packages/wordcount/src/defaultSettings.js Outdated Show resolved Hide resolved
packages/wordcount/src/defaultSettings.js Outdated Show resolved Hide resolved
packages/wordcount/src/index.js Show resolved Hide resolved
packages/wordcount/src/index.js Outdated Show resolved Hide resolved
packages/wordcount/src/index.js Outdated Show resolved Hide resolved
packages/wordcount/src/stripConnectors.js Outdated Show resolved Hide resolved
packages/wordcount/src/stripConnectors.js Outdated Show resolved Hide resolved
packages/wordcount/src/stripSpaces.js Outdated Show resolved Hide resolved
@nb
Copy link
Member Author

nb commented May 5, 2020

Thank you for the thorough review and comments, @aduth – I really appreciate it 🤩 Will be looking through it and addressing the feedback in the next day or two.

@nb nb requested a review from aduth May 6, 2020 13:08
@nb
Copy link
Member Author

nb commented May 6, 2020

@aduth the feedback should be address, feel free to have another look when you get a chance 🙇

Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

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

I've left a few thoughts for consideration, nothing major or blocking. Thanks for joining us on this typed adventure, @nb! Great to have you along 😁

packages/wordcount/src/index.js Outdated Show resolved Hide resolved
packages/wordcount/src/index.js Outdated Show resolved Hide resolved
packages/wordcount/src/defaultSettings.js Outdated Show resolved Hide resolved
packages/wordcount/src/index.js Show resolved Hide resolved
Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

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

I really like where you've taken this! It's so nice to see where we can refactor the internal interfaces supported by the type system. I've left some notes and comments, I'll follow up with a larger comment and a diff of some changes I've been exploring.

Thanks for continuing to iterate here!

packages/wordcount/src/defaultSettings.js Outdated Show resolved Hide resolved
packages/wordcount/src/defaultSettings.js Show resolved Hide resolved
packages/wordcount/src/index.js Outdated Show resolved Hide resolved
packages/wordcount/src/index.js Outdated Show resolved Hide resolved
packages/wordcount/src/defaultSettings.js Show resolved Hide resolved
@sirreal
Copy link
Member

sirreal commented May 26, 2020

I'll share some work I did exploring some ideas I alluded to in my review #22077 (review).

Some notes:

  • Use a strict settings interface, ignore the shape of defaultSettings and let type inference determine its shape.
  • Simplify loadSettings. loadSettings is an internal function and we can make pretty strong assumptions about the values it receives.
  • This change includes some defensive type/value juggling for type in the public count function. I explored this approach because, while our types say that type is one of 3 string values, this is a public JavaScript API, and I don't know how much we can trust it 🙂.
    This approach allows us to simplify the internal loadSettings method and have nice type guarantees.
/**
 * @param {string}                  text         The text being processed
 * @param {WPWordCountStrategy}     type	     The type of count. Accepts 'words', 'characters_excluding_spaces', or 'characters_including_spaces'.
 * @param {WPWordCountUserSettings} userSettings Custom settings object.
 * @return {number} The word or character count.
 */
export function count( text, type, userSettings ) {

	// Here, the `type` parameter has a strict type:
	// 'words' | 'characters_excluding_spaces' | 'characters_including_spaces'
	// But this is the JavaScript public API… we don't _really_ know what we get.

	// We _do_ want our public interface to claim the correct type, however!
	// Widening the public interface to `string` is undesirable.

	// We can widen this type internally:
	/** @type {string} */
	const userType = type;

	// Then we let the type system go from the wider `string` type back to our well known type.
	const validType =
		userType === 'characters_excluding_spaces' ||
		userType === 'characters_including_spaces'
			? userType
			: 'words';

	// Now our strict `loadSettings` function happily accepts our well-typed `validType`.
	const settings = loadSettings( validType, userSettings );
	// snip…
}

This is exploratory and I'm happy to discuss further. There are already some very nice changes here and I'm happy to do a more narrow review if you'd prefer. You did ask for all the nitpicking 🙂

Diff without space
diff --git a/packages/wordcount/src/defaultSettings.js b/packages/wordcount/src/defaultSettings.js
index 020f44162..90394832c 100644
--- a/packages/wordcount/src/defaultSettings.js
+++ b/packages/wordcount/src/defaultSettings.js
@@ -1,9 +1,13 @@
 /** @typedef {import('./index').WPWordCountStrategy} WPWordCountStrategy */
 
-/** @typedef {Partial<{type: WPWordCountStrategy, shortcodes: string[]}>} WPWordCountL10n */
+/**
+ * @typedef WPWordCountL10n
+ * @property {string[]} shortcodes Localized shortcodes.
+ */
 
 /**
- * @typedef WPWordCountSettingsFields
+ * @typedef WPWordCountSettings
+ *
  * @property {RegExp}                    HTMLRegExp                        Regular expression that matches HTML tags
  * @property {RegExp}                    HTMLcommentRegExp                 Regular expression that matches HTML comments
  * @property {RegExp}                    spaceRegExp                       Regular expression that matches spaces in HTML
@@ -14,27 +18,18 @@
  * @property {RegExp}                    wordsRegExp                       Regular expression that matches words
  * @property {RegExp}                    characters_excluding_spacesRegExp Regular expression that matches characters excluding spaces
  * @property {RegExp}                    characters_including_spacesRegExp Regular expression that matches characters including spaces
- * @property {RegExp}              shortcodesRegExp                  Regular expression that matches WordPress shortcodes
+ * @property {RegExp|undefined}          [shortcodesRegExp]                Regular expression that matches WordPress shortcodes
  * @property {string[]}                  shortcodes                        List of all shortcodes
  * @property {WPWordCountStrategy}       type                              Describes what and how are we counting
- * @property {WPWordCountL10n}     l10n                              Object with human translations
+ * @property {WPWordCountL10n|undefined} [l10n]                            Object with human translations
  */
 
-// Disable reason: JSDoc linter doesn't seem to parse the union (`&`) correctly: https://github.com/jsdoc/jsdoc/issues/1285
-/* eslint-disable jsdoc/valid-types */
 /**
  * Lower-level settings for word counting that can be overridden.
  *
- * @typedef {Partial<WPWordCountSettingsFields>} WPWordCountUserSettings
+ * @typedef {Partial<WPWordCountSettings>} WPWordCountUserSettings
  */
 
-/**
- * Word counting settings that include non-optional values we set if missing
- *
- * @typedef {WPWordCountUserSettings & typeof defaultSettings} WPWordCountDefaultSettings
- */
-/* eslint-enable jsdoc/valid-types */
-
 export const defaultSettings = {
 	HTMLRegExp: /<\/?[a-z][^>]*?>/gi,
 	HTMLcommentRegExp: /<!--[\s\S]*?-->/g,
@@ -108,7 +103,4 @@ export const defaultSettings = {
 	 * \u2029 = paragraph separator
 	 */
 	characters_including_spacesRegExp: /[^\f\n\r\t\v\u00AD\u2028\u2029]/g,
-	l10n: {
-		type: 'words',
-	},
 };
diff --git a/packages/wordcount/src/index.js b/packages/wordcount/src/index.js
index f1ccce2f7..eceb8d3c9 100644
--- a/packages/wordcount/src/index.js
+++ b/packages/wordcount/src/index.js
@@ -18,7 +18,7 @@ import stripSpaces from './stripSpaces';
 import transposeHTMLEntitiesToCountableChars from './transposeHTMLEntitiesToCountableChars';
 
 /**
- * @typedef {import('./defaultSettings').WPWordCountDefaultSettings}  WPWordCountSettings
+ * @typedef {import('./defaultSettings').WPWordCountSettings}     WPWordCountSettings
  * @typedef {import('./defaultSettings').WPWordCountUserSettings} WPWordCountUserSettings
  */
 
@@ -37,26 +37,18 @@ import transposeHTMLEntitiesToCountableChars from './transposeHTMLEntitiesToCoun
  * @return {WPWordCountSettings} The combined settings object to be used.
  */
 function loadSettings( type, userSettings ) {
-	const settings = extend( defaultSettings, userSettings );
+	const settings = extend( {}, defaultSettings, userSettings, {
+		shortcodes: userSettings.l10n?.shortcodes ?? [],
+		type,
+	} );
 
-	settings.shortcodes = settings.l10n?.shortcodes ?? [];
-
-	if ( settings.shortcodes && settings.shortcodes.length ) {
+	if ( settings.shortcodes?.length ) {
 		settings.shortcodesRegExp = new RegExp(
 			'\\[\\/?(?:' + settings.shortcodes.join( '|' ) + ')[^\\]]*?\\]',
 			'g'
 		);
 	}
 
-	settings.type = type || settings.l10n?.type;
-
-	if (
-		settings.type !== 'characters_excluding_spaces' &&
-		settings.type !== 'characters_including_spaces'
-	) {
-		settings.type = 'words';
-	}
-
 	return settings;
 }
 
@@ -121,7 +113,16 @@ function countCharacters( text, regex, settings ) {
  * @return {number} The word or character count.
  */
 export function count( text, type, userSettings ) {
-	const settings = loadSettings( type, userSettings );
+	/** @type {string} */
+	const userType = type;
+
+	const validType =
+		userType === 'characters_excluding_spaces' ||
+		userType === 'characters_including_spaces'
+			? userType
+			: 'words';
+
+	const settings = loadSettings( validType, userSettings );
 	let matchRegExp;
 	switch ( settings.type ) {
 		case 'words':
Full diff
diff --git a/packages/wordcount/src/defaultSettings.js b/packages/wordcount/src/defaultSettings.js
index 020f44162..90394832c 100644
--- a/packages/wordcount/src/defaultSettings.js
+++ b/packages/wordcount/src/defaultSettings.js
@@ -1,40 +1,35 @@
 /** @typedef {import('./index').WPWordCountStrategy} WPWordCountStrategy */
 
-/** @typedef {Partial<{type: WPWordCountStrategy, shortcodes: string[]}>} WPWordCountL10n */
-
 /**
- * @typedef WPWordCountSettingsFields
- * @property {RegExp}              HTMLRegExp                        Regular expression that matches HTML tags
- * @property {RegExp}              HTMLcommentRegExp                 Regular expression that matches HTML comments
- * @property {RegExp}              spaceRegExp                       Regular expression that matches spaces in HTML
- * @property {RegExp}              HTMLEntityRegExp                  Regular expression that matches HTML entities
- * @property {RegExp}              connectorRegExp                   Regular expression that matches word connectors, like em-dash
- * @property {RegExp}              removeRegExp                      Regular expression that matches various characters to be removed when counting
- * @property {RegExp}              astralRegExp                      Regular expression that matches astral UTF-16 code points
- * @property {RegExp}              wordsRegExp                       Regular expression that matches words
- * @property {RegExp}              characters_excluding_spacesRegExp Regular expression that matches characters excluding spaces
- * @property {RegExp}              characters_including_spacesRegExp Regular expression that matches characters including spaces
- * @property {RegExp}              shortcodesRegExp                  Regular expression that matches WordPress shortcodes
- * @property {string[]}            shortcodes                        List of all shortcodes
- * @property {WPWordCountStrategy} type                              Describes what and how are we counting
- * @property {WPWordCountL10n}     l10n                              Object with human translations
+ * @typedef WPWordCountL10n
+ * @property {string[]} shortcodes Localized shortcodes.
+ */
+
+/**
+ * @typedef WPWordCountSettings
+ *
+ * @property {RegExp}                    HTMLRegExp                        Regular expression that matches HTML tags
+ * @property {RegExp}                    HTMLcommentRegExp                 Regular expression that matches HTML comments
+ * @property {RegExp}                    spaceRegExp                       Regular expression that matches spaces in HTML
+ * @property {RegExp}                    HTMLEntityRegExp                  Regular expression that matches HTML entities
+ * @property {RegExp}                    connectorRegExp                   Regular expression that matches word connectors, like em-dash
+ * @property {RegExp}                    removeRegExp                      Regular expression that matches various characters to be removed when counting
+ * @property {RegExp}                    astralRegExp                      Regular expression that matches astral UTF-16 code points
+ * @property {RegExp}                    wordsRegExp                       Regular expression that matches words
+ * @property {RegExp}                    characters_excluding_spacesRegExp Regular expression that matches characters excluding spaces
+ * @property {RegExp}                    characters_including_spacesRegExp Regular expression that matches characters including spaces
+ * @property {RegExp|undefined}          [shortcodesRegExp]                Regular expression that matches WordPress shortcodes
+ * @property {string[]}                  shortcodes                        List of all shortcodes
+ * @property {WPWordCountStrategy}       type                              Describes what and how are we counting
+ * @property {WPWordCountL10n|undefined} [l10n]                            Object with human translations
  */
 
-// Disable reason: JSDoc linter doesn't seem to parse the union (`&`) correctly: https://github.com/jsdoc/jsdoc/issues/1285
-/* eslint-disable jsdoc/valid-types */
 /**
  * Lower-level settings for word counting that can be overridden.
  *
- * @typedef {Partial<WPWordCountSettingsFields>} WPWordCountUserSettings
+ * @typedef {Partial<WPWordCountSettings>} WPWordCountUserSettings
  */
 
-/**
- * Word counting settings that include non-optional values we set if missing
- *
- * @typedef {WPWordCountUserSettings & typeof defaultSettings} WPWordCountDefaultSettings
- */
-/* eslint-enable jsdoc/valid-types */
-
 export const defaultSettings = {
 	HTMLRegExp: /<\/?[a-z][^>]*?>/gi,
 	HTMLcommentRegExp: /<!--[\s\S]*?-->/g,
@@ -108,7 +103,4 @@ export const defaultSettings = {
 	 * \u2029 = paragraph separator
 	 */
 	characters_including_spacesRegExp: /[^\f\n\r\t\v\u00AD\u2028\u2029]/g,
-	l10n: {
-		type: 'words',
-	},
 };
diff --git a/packages/wordcount/src/index.js b/packages/wordcount/src/index.js
index f1ccce2f7..eceb8d3c9 100644
--- a/packages/wordcount/src/index.js
+++ b/packages/wordcount/src/index.js
@@ -18,8 +18,8 @@ import stripSpaces from './stripSpaces';
 import transposeHTMLEntitiesToCountableChars from './transposeHTMLEntitiesToCountableChars';
 
 /**
- * @typedef {import('./defaultSettings').WPWordCountDefaultSettings}  WPWordCountSettings
- * @typedef {import('./defaultSettings').WPWordCountUserSettings}     WPWordCountUserSettings
+ * @typedef {import('./defaultSettings').WPWordCountSettings}     WPWordCountSettings
+ * @typedef {import('./defaultSettings').WPWordCountUserSettings} WPWordCountUserSettings
  */
 
 /**
@@ -37,26 +37,18 @@ import transposeHTMLEntitiesToCountableChars from './transposeHTMLEntitiesToCoun
  * @return {WPWordCountSettings} The combined settings object to be used.
  */
 function loadSettings( type, userSettings ) {
-	const settings = extend( defaultSettings, userSettings );
+	const settings = extend( {}, defaultSettings, userSettings, {
+		shortcodes: userSettings.l10n?.shortcodes ?? [],
+		type,
+	} );
 
-	settings.shortcodes = settings.l10n?.shortcodes ?? [];
-
-	if ( settings.shortcodes && settings.shortcodes.length ) {
+	if ( settings.shortcodes?.length ) {
 		settings.shortcodesRegExp = new RegExp(
 			'\\[\\/?(?:' + settings.shortcodes.join( '|' ) + ')[^\\]]*?\\]',
 			'g'
 		);
 	}
 
-	settings.type = type || settings.l10n?.type;
-
-	if (
-		settings.type !== 'characters_excluding_spaces' &&
-		settings.type !== 'characters_including_spaces'
-	) {
-		settings.type = 'words';
-	}
-
 	return settings;
 }
 
@@ -121,7 +113,16 @@ function countCharacters( text, regex, settings ) {
  * @return {number} The word or character count.
  */
 export function count( text, type, userSettings ) {
-	const settings = loadSettings( type, userSettings );
+	/** @type {string} */
+	const userType = type;
+
+	const validType =
+		userType === 'characters_excluding_spaces' ||
+		userType === 'characters_including_spaces'
+			? userType
+			: 'words';
+
+	const settings = loadSettings( validType, userSettings );
 	let matchRegExp;
 	switch ( settings.type ) {
 		case 'words':

@nb
Copy link
Member Author

nb commented Jun 9, 2020

@sirreal I am having a hard time understanding what problem does this diff solve? I see that we simplified one function for the sake of moving the same logic to another at the same level of abstraction (even if one level down in indirection). We had the extra safety for the type argument before, it just lived in loadSettings and latter we used settings.type.

@nb nb requested a review from sirreal June 9, 2020 20:52
@sirreal
Copy link
Member

sirreal commented Jun 26, 2020

Thanks for your patience, I've had a lot come up that has prevented me from giving this the focus it deserves.

I'll try to clarify my reasoning, but I'll preface a lengthy response by saying that, after additional scrutiny, it doesn't work as well as I expected 🙂

I'll elaborate…

I am having a hard time understanding what problem does this diff solve?

My goals were:

  • Describe a single interface for settings.
  • The interface is described by our type, not by the implementation of defaults (no typeof defaultSettings).
  • We leverage the single interface to check:
    • The settings passed around internally in the package.
    • The shape of defaultSettings based on its inferred type.

The significant improvement I see is that we describe a single interface and use it to control several types. Rather than defaultSettings widening the Settings type used throughout the package (Settings & typeof defaultSettings), we use Settings and loadSettings the ensure that the inferred defaultSettings type is what we expect (it fits Settings and guarantees that we provide the required values).

Unfortunately, while this appeared to work very well in the implementation I shared, I've continued testing and the type system doesn't provide the safety I'd hoped. For example:

import { extend } from "lodash";

export type Options = {
  type: string;
  enabled: boolean;
};

// ⚠️ This type includes `{ enabled: string }`, it should be incompatible with `Options`
const defaultOptions = {
  type: "default-type",
  enabled: "baz"
};

// I'd expect a type error here, but there are none 😫
export function parseOptions(options: Partial<Options>): Options {
  return extend({}, defaultOptions, options);
}

The crux of this seems to be that extend (or Object.assign and similar) returns an intersection of the provided objects:

function assignIn<TObject, TSource1, TSource2>(
  object: TObject,
  source1: TSource1,
  source2: TSource2
): TObject & TSource1 & TSource2;

It's surprising to me that (irrelevant properties omitted) { enabled: string } & { enabled?: boolean } somehow satisfies the return type { enabled: boolean }. In my testing { enabled: string } & { enabled: boolean } results in { enabled: never }.

This may be related to microsoft/TypeScript#12936.

@sirreal
Copy link
Member

sirreal commented Jun 26, 2020

I see that we simplified one function for the sake of moving the same logic to another at the same level of abstraction (even if one level down in indirection).

Regarding type, my ideas are about when our internal type safety meets the public API.

We had the extra safety for the type argument before, it just lived in loadSettings and latter we used settings.type.

We did:

settings.type = type;
if (
settings.type !== 'characters_excluding_spaces' &&
settings.type !== 'characters_including_spaces'
) {
settings.type = 'words';
}

However, the type of type in this function is already 'words' | 'characters_excluding_spaces' | 'characters_including_spaces', which makes the block look redundant. The same is true of type in count, but I proposed explicitly widening and narrowing it. The same technique could also be applied in loadSettings.

Code sketching

This will log:

If you see this, something unexpected happened… 
Unsafe: foo 
Safe: B

TypeScript module:

type CountType = "A" | "B";

interface Settings {
  type: CountType;
}

// Public API
// This is great as long as it's invoked by well-typed code.
// However, we have very weak guarantees about JavaScript consumers.
export function takesType(type: CountType): CountType {
  // Our type is already infected if type isn't actually CountType 😱
  const s: Settings = makeSettings(type);

  switch (s.type) {
    case "A":
    case "B":
      return typeFromSettings(s);
  }

  // We're don't need to provide a return here according to the type system because all possible branches have returns!
  // This section is flagged as unreachable!
  // I'll include a bit more here…
  console.log("If you see this, something unexpected happened…");
  return typeFromSettings(s);
}

// Public API still claims type is CountType
export function safeTakesType(type: CountType): CountType {
  // Here, we're defensive. We don't trust the user provided `type`, so we cast to `unknown`
  // and rely on the type system to narrow the type.
  const userType: unknown = type;
  const safeType = userType === "A" ? userType : "B";

  const s: Settings = makeSettings(safeType);

  switch (s.type) {
    case "A":
    case "B":
      return typeFromSettings(s);
  }
}

function makeSettings(type: CountType): Settings {
  return { type };
}

function typeFromSettings(s: Settings): CountType {
  return s.type;
}

Consumer (JavaScript)

import { safeTakesType, takesType } from "./type";

console.log("Unsafe:", takesType("foo"));
console.log("Safe:", safeTakesType("foo"));

Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

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

Thanks @nb! The changes are nice and I'm looking forward to ticking another package off the "typed" list.

This does need a rebase, but the changes look good and the tests continue to pass without modifications. All the code changes seem to be for the better 🙂

I engaged in more academic exercises in this review than I usually would and I appreciate your patience, I learned a few things and I hope it was informative for you as well. None of my recent comments are blocking; they're part of the investigative and exploratory review.

Please, land this at your leisure 👍

Comment on lines +126 to +137
switch ( settings.type ) {
case 'words':
matchRegExp = settings.wordsRegExp;
return countWords( text, matchRegExp, settings );
case 'characters_including_spaces':
matchRegExp = settings.characters_including_spacesRegExp;
return countCharacters( text, matchRegExp, settings );
case 'characters_excluding_spaces':
matchRegExp = settings.characters_excluding_spacesRegExp;
return countCharacters( text, matchRegExp, settings );
default:
return 0;
Copy link
Member

Choose a reason for hiding this comment

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

I really like how this section turned out. It's nice that the count…() functions return a number result.

@youknowriad
Copy link
Contributor

@nb @sirreal I see this is approved but stale a bit, can we refresh it and merge it or is there anything is blocking?

@sirreal
Copy link
Member

sirreal commented Oct 1, 2020

I don't think there's anything blocking. I'll try to circle around and finish this in the next few days. @nb let me know if you'd prefer to handle it.

It’s a constant set of strings.
For consistency – all the rest of the strip* functions work like this.

Those aren’t exported outside of the wordcount package, so should be
safe to change the semantics.
Since the functions don't use this, we can safely use null. Also helps
with type-checking :-)

We still need the bind, even if it's not a class, because of `settings`.
Not sure if syntax like `text => stripTags( settings, text )` will be
more readable, considered it, but decided to liimt the surface of my
changes.
nb and others added 14 commits October 13, 2020 17:21
This hides their complexity (including complex return types) inside
themselves and makes makes the final `count()` function simpler.

As for the `count()` function, I considered adding a TypeScript ignore,
though the chance for adding a fourth type and not dealing with it here
is real. Also, the three `if` clauses aren't so hard to read.
Honestly, mostly to make the linter happy, not sure how useful they are.
I totally forgot about the convention in this context. Of course, I
would have expected some of the tools to have mentioned it along the
way, but alas…
In order to do that the type descriptions moved to the source, instead
of some of them living next to the import.

One trade-off for me was that VSCode now doesn’t pick up the
descriptions of the imported types.
Has better rhythm than the `if`s (for me, it’s subjective). I had tried
the `switch` before, but I didn’t think of skipping the `break`s. We
don’t need them with all the `returns.

Technically we don’t need the `return 0` since we’re exhausting all
options for `type` union. However, I’d rather err on the side of
caution for cases when the codebase isn’t type checked.

Props @aduth.
See https://make.wordpress.org/core/handbook/best-practices/inline-documentation-standards/javascript/#aligning-comments

For the record, because of some really long strings it’s not more 
readable to me.
The idea came from @sirreal ([0], [1]) to use the already defined keys
in defaultSettings as a natural way to have a stronger type guarantee.

Once we have the narrower type, we can distinguish between settings
object we expect from the user, where everything is optional and a
settings object that comes from `loadSettings()` where we know that a
massive amount of the properties have been set as part of `defaultSettings`.

[0] https://git.io/JfVWj
[1] https://git.io/JfVle
Since we have a typecheck guarantee that most properties are there,
we don’t need to do all those checks.

Note that we still do the checks for `shortcodesRegExp` and `type`. Since
the guarantee for them doesn’t come from `defaultSettings`, but from
`loadSettings()`, we would have needed yet another layer of type to
describe the settings coming from `defaultSettings` + `type`, `shortcodes`,
and `shortcodesRegExp`.

Personally, this crossed a line, and I decided
that leaving the checks in `stripShortcodes.js` and the `default` in the
`switch` in `count()` were a smaller price to pay than adding yet another
type and dealing with type trickery (would’ve needed a `Pick` and to
disable `jsdoc/valid-types` again).
Doesn’t change functionality much, but it was more confusing spanning
multiple declarations.
`extend` is an alias for [assignIn](https://lodash.com/docs/4.17.15#assignIn):

> This method is like _.assign except that it iterates over own and inherited source properties.
> Note: This method mutates object.

Props @sirreal.

Co-authored-by: Jon Surrell <jon.surrell@automattic.com>
We know that `type` can’t be missing or falsy, so we can skip the check.
@sirreal
Copy link
Member

sirreal commented Oct 13, 2020

Rebased

Copy link
Contributor

@ockham ockham left a comment

Choose a reason for hiding this comment

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

Approving. Merge pending CI 👍

I've read through your suggestions @sirreal but I'm afraid I'll need a bit longer to let them sink in 😬 and I'd rather get this un-stuck. We can iterate later if we decide that some types can use some more tightening 👍

@ockham ockham merged commit e2d8b7a into master Oct 19, 2020
@ockham ockham deleted the add/types-wordcount branch October 19, 2020 15:00
@github-actions github-actions bot added this to the Gutenberg 9.2 milestone Oct 19, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Word count /packages/wordcount [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants