From 7e2c31f8d1221aee08d435e7cf266804ba9497da Mon Sep 17 00:00:00 2001 From: rawpixel-vincent Date: Thu, 7 Mar 2024 01:10:43 +0700 Subject: [PATCH] add strict/mutable function in two different export and remove prototype overrides to prevent errors when frozen --- README.md | 9 +- StringLiteralList.d.ts | 114 +++-- StringLiteralList.js | 95 ++-- package-lock.json | 4 +- package.json | 7 +- strict.d.ts | 5 + strict.js | 5 + stringList.d.ts | 6 +- stringList.js | 34 +- stringList.test.js | 941 +++++++++++++++++++++------------------- stringListFunction.d.ts | 5 + stringListFunction.js | 36 ++ types.d.ts | 58 ++- 13 files changed, 758 insertions(+), 561 deletions(-) create mode 100644 strict.d.ts create mode 100644 strict.js create mode 100644 stringListFunction.d.ts create mode 100644 stringListFunction.js diff --git a/README.md b/README.md index 9d543a1..59a741c 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,11 @@ Useful for types constructs that can be used at runtime. *If you code in typescript, you probably don't need any of this.* -Thanks to @gustavoguichard and his work on https://github.com/gustavoguichard/string-ts that taught me how to work with string literals. - ## Overview The StringList class extends the Array interface types to work with string literals. -- immutable: methods that mutate the array in place like push, pop, shift, unshift, splice are not working, it's possible to escape the list to a mutable array with the method `mutable()` +- immutable: methods that mutate the array in place like push, pop, shift, unshift, splice should not be used, there is a strict export that enforce this by freezing the instance when constructed. - inference: concat, toReversed, toSorted and slice methods are implemented to return a new frozen instance and will infer the new values. - the concat method parameters types has been updated to accept strings literals and/or instances of StringList. @@ -56,6 +54,7 @@ yarn add string-literal-list ```js import { stringList } from 'string-literal-list'; +// Or import { stringList } from 'string-literal-list/strict.js'; with frozen array. let v = stringList("foo", "bar", ...) => SL<"foo" | "bar">; @@ -214,3 +213,7 @@ lit.concat(val); // Overload 2 of 2, '(...items: ("foo" | "bar" | ConcatArray<"foo" | "bar">)[]): ("foo" | "bar")[]', gave the following error. // Argument of type 'string' is not assignable to parameter of type '"foo" | "bar" | ConcatArray<"foo" | "bar">'.ts(2769) ``` + +## Credits + +Thanks to @gustavoguichard and his work on https://github.com/gustavoguichard/string-ts that taught me how to work with string literals. diff --git a/StringLiteralList.d.ts b/StringLiteralList.d.ts index 232842c..e5e08fc 100644 --- a/StringLiteralList.d.ts +++ b/StringLiteralList.d.ts @@ -1,47 +1,86 @@ import { sl } from './types.js'; +export type ArrayInPlaceMutation = { + push: 'push'; + unshift: 'unshift'; + shift: 'shift'; + copyWithin: 'copyWithin'; + pop: 'pop'; + fill: 'fill'; + splice: 'splice'; +}; + +export const ARRAY_IN_PLACE_MUTATION: ArrayInPlaceMutation; + + interface ILiterals { literal: T; } -export interface IStringList +export type MaybeReadonly = [T] extends [true] ? Readonly : A; + +export interface IStringList extends Omit< Array, sl.specs.ImplementedMethod >, ILiterals { + // Custom Methods withPrefix

( prefix: P, - ): IStringList>; + ): MaybeReadonly, Mut>>; withSuffix

( suffix: P, - ): IStringList>; + ): MaybeReadonly, Mut>>; + withDerivatedSuffix( chars: S - ): IStringList>, S>, sl.utils.StringConcat>>; + ): MaybeReadonly, + `${S}${S}` + >, + Mut>>; + withDerivatedPrefix( chars: S - ): IStringList, T>, S>, sl.utils.StringConcat>>; - withReplace< - S extends string | RegExp, - D extends string - >(searchValue: S, replaceValue: D): IStringList>; - withReplaceAll< - S extends string | RegExp, - D extends string - >(searchValue: S, replaceValue: D): IStringList>; - withTrim(): IStringList>; + ): MaybeReadonly, + `${S}${S}` + >, + Mut>>; + + withReplace( + searchValue: S, + replaceValue: D + ): MaybeReadonly, Mut>>; + + withReplaceAll( + searchValue: S, + replaceValue: D + ): MaybeReadonly, Mut>>; + + withTrim(): MaybeReadonly, Mut>>; value(val): T; mutable(): T & string[]; sort(compareFn?: (a: P1, b: P2) => number): this; reverse(): this; - without(...arg: (ILiterals | S)[]): IStringList>; + without(...arg: (ILiterals | S)[]): MaybeReadonly, Mut>>; // Implemented methods to return the frozen array, typed as IStringList. - toSorted(compareFn?: (a: T, b: T) => number): IStringList; - toReversed(): IStringList; + toSorted(compareFn?: (a: T, b: T) => number): MaybeReadonly>; + toReversed(): IStringList; - concat(...arg: (ILiterals | S)[]): IStringList; + concat(...arg: (ILiterals | S)[]): MaybeReadonly>; // Readonly overrides readonly length: number; readonly [n: number]: T | undefined; @@ -71,7 +110,7 @@ export interface IStringList ): T; findIndex(predicate: (value: S & string, index: number, obj: T[]) => unknown, thisArg?: any): number; - slice(start?: number, end?: number): IStringList; + slice(start?: number, end?: number): MaybeReadonly>; some(predicate: (value: S & string, index: number, array: T[]) => unknown, thisArg?: any): boolean; every(predicate: (value: S & string, index: number, array: T[]) => value is S & string, thisArg?: any): this is S[]; @@ -89,21 +128,30 @@ export interface IStringList thisArg?: any, ): T & string[]; toSpliced(start: number, deleteCount: number, ...items: string[]): (S & string)[]; - pop(): T; + pop(): [Mut] extends [true] ? T : never; + shift(): [Mut] extends [true] ? T : never; + unshift(...newElement: S[]): [Mut] extends [true] ? number : never; + push(...items: S[]): [Mut] extends [true] ? number : never; + splice(start: number, deleteCount?: number): [Mut] extends [true] + ? T[] + : never; + copyWithin(target: number, start: number, end?: number): [Mut] extends [true] ? S[] : never; + fill( + value: (U | undefined)[], + start?: number, + end?: number, + ): [Mut] extends [true] ? U[] : never; + fill( + value: U, + start?: number, + end?: number, + ): [Mut] extends [true] ? U[] : never; + // join(delimiter?: D): sl.utils.Join; } -export class SL { - constructor(...list: T[]); -} -export interface ArrayInPlaceMutation { - push: 'push'; - unshift: 'unshift'; - shift: 'shift'; - copyWithin: 'copyWithin'; - pop: 'pop'; - fill: 'fill'; - splice: 'splice'; +export class SL { + literal: undefined; + constructor(...list: string[]); + mutable(): string[]; } - -export const ARRAY_IN_PLACE_MUTATION: ArrayInPlaceMutation; diff --git a/StringLiteralList.js b/StringLiteralList.js index 94b90fe..ee4c9e8 100644 --- a/StringLiteralList.js +++ b/StringLiteralList.js @@ -3,20 +3,33 @@ import 'core-js/actual/array/to-sorted.js'; import 'core-js/actual/array/to-spliced.js'; import 'core-js/actual/array/with.js'; +const freezeIfImmutable = (source, target) => { + if (Object.isFrozen(source)) { + return Object.freeze(target); + } + return target; +}; + export class SL extends Array { literal = undefined; enum; hasEmpty = false; constructor(...args) { - super(...args); - this.enum = Object.fromEntries( - this.map((v) => { - if (v === '') { - this.hasEmpty = true; + const entries = []; + const arr = []; + let emptyFound = false; + for (const str of args.flat()) { + if (typeof str === 'string') { + if (str === '') { + emptyFound = true; } - return [v, v]; - }), - ); + entries.push([str, str]); + arr.push(str); + } + } + super(...arr); + this.hasEmpty = emptyFound; + this.enum = Object.fromEntries(entries); if (this.hasEmpty) { this.enum[''] = ''; @@ -25,19 +38,31 @@ export class SL extends Array { } concat(...args) { - return Object.freeze(new SL(...super.concat.apply(this, args.flat()))); + return freezeIfImmutable( + this, + new SL(...super.concat.apply(this, args.flat())), + ); } toSorted() { - return Object.freeze(new SL(...super.toSorted.apply(this, arguments))); + return freezeIfImmutable( + this, + new SL(...super.toSorted.apply(this, arguments)), + ); } toReversed() { - return Object.freeze(new SL(...super.toReversed.apply(this, arguments))); + return freezeIfImmutable( + this, + new SL(...super.toReversed.apply(this, arguments)), + ); } slice() { - return Object.freeze(new SL(...super.slice.apply(this, arguments))); + return freezeIfImmutable( + this, + new SL(...super.slice.apply(this, arguments)), + ); } without() { @@ -48,23 +73,33 @@ export class SL extends Array { ? [el] : [], ); - return Object.freeze(new SL(...this.filter((e) => !values.includes(e)))); + return freezeIfImmutable( + this, + new SL(...this.filter((e) => !values.includes(e))), + ); } withTrim() { - return Object.freeze(new SL(...super.map((e) => e.trim()))); + return freezeIfImmutable(this, new SL(...super.map((e) => e.trim()))); } withPrefix(prefix = '') { - return Object.freeze(new SL(...super.map((e) => `${prefix}${e}`))); + return freezeIfImmutable( + this, + new SL(...super.map((e) => `${prefix}${e}`)), + ); } withSuffix(suffix = '') { - return Object.freeze(new SL(...super.map((e) => `${e}${suffix}`))); + return freezeIfImmutable( + this, + new SL(...super.map((e) => `${e}${suffix}`)), + ); } withDerivatedSuffix(chars = '') { - return Object.freeze( + return freezeIfImmutable( + this, new SL( ...super.flatMap((t) => [ t, @@ -77,7 +112,8 @@ export class SL extends Array { } withDerivatedPrefix(chars = '') { - return Object.freeze( + return freezeIfImmutable( + this, new SL( ...super.flatMap((t) => [ t, @@ -90,13 +126,15 @@ export class SL extends Array { } withReplace(string, replacement = '') { - return Object.freeze( + return freezeIfImmutable( + this, new SL(...super.map((e) => e.replace(string, replacement))), ); } withReplaceAll(string, replacement = '') { - return Object.freeze( + return freezeIfImmutable( + this, new SL(...super.map((e) => e.replaceAll(string, replacement))), ); } @@ -113,7 +151,7 @@ export class SL extends Array { // Get the native array mutable() { - return Array.from(this.values()); + return Array.from(this); } // Methods returning the native array @@ -168,17 +206,4 @@ export const ARRAY_IN_PLACE_MUTATION = Object.freeze({ splice: 'splice', }); -Object.values(ARRAY_IN_PLACE_MUTATION).forEach((el) => { - SL.prototype[el] = function () { - /* c8 ignore start */ - if ( - typeof window === 'undefined' && - process?.env?.NODE_ENV !== 'production' && - process?.env?.NODE_ENV !== 'test' - ) { - console && console.debug(`Array method ${el} is not supported`); - } - /* c8 ignore stop */ - return Array.prototype[el].apply(this.mutable(), arguments); - }; -}); +export class SLS extends SL {} diff --git a/package-lock.json b/package-lock.json index a58c981..2c7619b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "string-literal-list", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "string-literal-list", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "core-js": "latest" diff --git a/package.json b/package.json index c2bb335..0e5b030 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "string-literal-list", - "version": "1.8.0", + "version": "1.9.0", "description": "an array for string literal", "main": "stringList.js", "types": "stringList.d.ts", "exports": { ".": { "import": "./stringList.js" + }, + "./strict.js": { + "import": "./strict.js" } }, "engines": { @@ -27,7 +30,7 @@ "type": "module", "repository": { "type": "git", - "url": "git+https://github.com/rawpixel-vincent/string-literal-list.git" + "url": "https://github.com/rawpixel-vincent/string-literal-list.git" }, "devDependencies": { "eslint": "latest", diff --git a/strict.d.ts b/strict.d.ts new file mode 100644 index 0000000..a8dfd39 --- /dev/null +++ b/strict.d.ts @@ -0,0 +1,5 @@ +import { IStringList } from './StringLiteralList.js'; + +export function stringList( + ...strings: TT +): IStringList; diff --git a/strict.js b/strict.js new file mode 100644 index 0000000..1be6f2e --- /dev/null +++ b/strict.js @@ -0,0 +1,5 @@ +// @ts-check +/// +import { stringListReadonly } from './stringListFunction.js'; + +export const stringList = stringListReadonly; diff --git a/stringList.d.ts b/stringList.d.ts index bfa43aa..0737eae 100644 --- a/stringList.d.ts +++ b/stringList.d.ts @@ -1,5 +1,5 @@ import { IStringList } from './StringLiteralList.js'; -export function stringList( - ...strings: T[] -): IStringList[T]>; +export function stringList( + ...strings: TT +): IStringList; diff --git a/stringList.js b/stringList.js index c98d4bf..818caaf 100644 --- a/stringList.js +++ b/stringList.js @@ -1,35 +1,5 @@ // @ts-check /// +import { stringListMutable } from './stringListFunction.js'; -import { SL } from './StringLiteralList.js'; - -/** @type {import('./stringList.js').stringList} */ -export function stringList(...strings) { - let values = strings; - let invalid = strings.some((el) => typeof el !== 'string'); - if (strings.length && invalid) { - /* c8 ignore start */ - if ( - typeof window === 'undefined' && - process?.env?.NODE_ENV !== 'production' && - process?.env?.NODE_ENV !== 'test' - ) { - console.debug( - `Unexpected type in stringList(${typeof invalid}). Casting all arguments to string type.`, - ); - } - /* c8 ignore stop */ - values = strings.flatMap((el) => - Array.isArray(el) - ? el.filter((s) => typeof s === 'string') - : typeof el === 'string' - ? [el] - : typeof el === 'number' - ? [String(el)] - : [], - ); - } - - // @ts-expect-error - return Object.freeze(new SL(...values)); -} +export const stringList = stringListMutable; diff --git a/stringList.test.js b/stringList.test.js index bd10a88..e85d30e 100644 --- a/stringList.test.js +++ b/stringList.test.js @@ -1,466 +1,507 @@ +'use strict'; import t from 'tap'; -import { SL, ARRAY_IN_PLACE_MUTATION } from './StringLiteralList.js'; - -import { stringList } from './stringList.js'; - -/** - * @param {import('tap').Test} st - * @param {any} list - * @param {...string} values - */ -const testExpectedArrayValues = (st, list, ...values) => { - st.ok(list.constructor.name === 'SL'); - st.notOk(list.constructor.name === 'Array'); - st.match(list, new SL(...values)); - st.match(list, { - length: values.length, - }); - st.match([...list], values); - st.match(JSON.stringify([...list]), JSON.stringify(values)); - st.match([...list.keys()], [...values.keys()]); - st.match([...list.values()], values); - st.match([...list.entries()], [...values.entries()]); - for (const [i, value] of list.entries()) { - st.match(value, values[i]); - st.match(list.enum[value], value); - st.ok(list.includes(value)); - st.ok(list.indexOf(values[i]) === i); - st.ok(list.at(i) === value); - if (Array.prototype.findLastIndex) { - st.ok(list.findLastIndex((v) => v === value) === i); +import { ARRAY_IN_PLACE_MUTATION, SL } from './StringLiteralList.js'; + +import { stringList as immutableStringList } from './strict.js'; +import { stringList as mutableStringList } from './stringList.js'; + +const functions = [ + { + type: 'immutable', + stringList: immutableStringList, + }, + { + type: 'mutable', + stringList: mutableStringList, + }, +]; + +for (const { type, stringList } of functions) { + /** + * @param {import('tap').Test} st + * @param {any} list + * @param {...string} values + */ + const testExpectedArrayValues = (st, list, ...values) => { + st.ok(list.constructor.name === 'SL'); + st.notOk(list.constructor.name === 'Array'); + st.match(list, new SL(...values)); + st.match(list, { + length: values.length, + }); + st.match([...list], values); + st.match(JSON.stringify([...list]), JSON.stringify(values)); + st.match([...list.keys()], [...values.keys()]); + st.match([...list.values()], values); + st.match([...list.entries()], [...values.entries()]); + for (const [i, value] of list.entries()) { + st.match(value, values[i]); + st.match(list.enum[value], value); + st.ok(list.includes(value)); + st.ok(list.indexOf(values[i]) === i); + st.ok(list.at(i) === value); + if (Array.prototype.findLastIndex) { + st.ok(list.findLastIndex((v) => v === value) === i); + } + st.ok(list.findIndex((v) => v === value) === i); + st.ok(list.some((v) => v === value) === true); + st.ok(list.every((v) => v === value) === (list.length === 1)); + st.ok(list.value(value) === value); + } + + st.ok(list.enum[`${Math.random() * 100000}!`] === undefined); + st.ok(list.enum[Math.random() * 100000] === undefined); + st.throws(() => list.value(`${Math.random() * 100000}`)); + + st.notOk(list.includes(null)); + st.ok(list.at(values.length) === undefined); + }; + + /** + * @param {import('tap').Test} st + * @param {any} list + * @param {...string} values + */ + const testEscapingFromStringList = (st, list, ...values) => { + st.ok(list.constructor.name === 'SL'); + + // map() + const fromMap = list.map((el) => el); + st.ok(fromMap.constructor.name === 'Array'); + st.ok(fromMap.length === list.length); + st.match(fromMap, list); + st.match(JSON.stringify([...list]), JSON.stringify(fromMap)); + if (values.length > 0) { + list + .map((e) => typeof e === 'string' && e.toUpperCase()) + .includes(values[0].toUpperCase()); } - st.ok(list.findIndex((v) => v === value) === i); - st.ok(list.some((v) => v === value) === true); - st.ok(list.every((v) => v === value) === (list.length === 1)); - st.ok(list.value(value) === value); - } - - st.ok(list.enum[`${Math.random() * 100000}!`] === undefined); - st.ok(list.enum[Math.random() * 100000] === undefined); - st.throws(() => list.value(`${Math.random() * 100000}`)); - - st.notOk(list.includes(null)); - st.ok(list.at(values.length) === undefined); -}; - -/** - * @param {import('tap').Test} st - * @param {any} list - * @param {...string} values - */ -const testEscapingFromStringList = (st, list, ...values) => { - st.ok(list.constructor.name === 'SL'); - - // map() - const fromMap = list.map((el) => el); - st.ok(fromMap.constructor.name === 'Array'); - st.ok(fromMap.length === list.length); - st.match(fromMap, list); - st.match(JSON.stringify([...list]), JSON.stringify(fromMap)); - if (values.length > 0) { - list - .map((e) => typeof e === 'string' && e.toUpperCase()) - .includes(values[0].toUpperCase()); - } - - // filter() - const fromFilter = list.filter(() => true); - st.ok(fromFilter.constructor.name === 'Array'); - st.ok(fromFilter.length === list.length); - st.match(fromFilter, list); - st.match(JSON.stringify([...list]), JSON.stringify(fromFilter)); - - // reduce() - const fromReduce = list.reduce((acc, el) => acc.concat(el), []); - st.ok(fromReduce.constructor.name === 'Array'); - st.ok(fromReduce.length === list.length); - st.match(fromReduce, list); - st.match(JSON.stringify([...list]), JSON.stringify(fromReduce)); - - // reduceRight() - const fromReduceRight = list.reduceRight((acc, el) => acc.concat(el), []); - st.ok(fromReduceRight.constructor.name === 'Array'); - st.ok(fromReduceRight.length === list.length); - st.match(fromReduceRight.reverse(), list); - st.match(JSON.stringify([...list]), JSON.stringify(fromReduceRight)); - - // flat() - const fromFlat = list.flat(); - st.ok(fromFlat.constructor.name === 'Array'); - st.ok(fromFlat.length === list.length); - st.match(fromFlat, list); - st.match(JSON.stringify([...list]), JSON.stringify(fromFlat)); - - // flatMap() - const fromFlatMap = list.flatMap((el) => [el]); - st.ok(fromFlatMap.constructor.name === 'Array'); - st.ok(fromFlatMap.length === list.length); - st.match(fromFlatMap, list); - st.match(JSON.stringify([...list]), JSON.stringify(fromFlatMap)); - - // toSpliced() - const fromSpliced = list.toSpliced(0, 0); - st.ok(fromSpliced.constructor.name === 'Array'); - st.ok(fromSpliced.length === list.length); - st.match(fromSpliced, list); - st.match(JSON.stringify([...list]), JSON.stringify(fromSpliced)); - - // with() - if (values.length > 0) { - const fromWith = list.with(0, 'a'); - st.ok(fromWith.constructor.name === 'Array'); - st.ok(fromWith.length === list.length); - st.notMatch(fromWith, list); - st.notMatch(fromWith, values); - st.match([...fromWith.slice(1)], values.slice(1)); - st.match(fromWith.concat(...list), fromWith); - } else { - if (!process.version.match(/^v1[2-8]\./)) { - st.throws(() => list.with(0, 'a'), new Error('Invalid index : 0')); + + // filter() + const fromFilter = list.filter(() => true); + st.ok(fromFilter.constructor.name === 'Array'); + st.ok(fromFilter.length === list.length); + st.match(fromFilter, list); + st.match(JSON.stringify([...list]), JSON.stringify(fromFilter)); + + // reduce() + const fromReduce = list.reduce((acc, el) => acc.concat(el), []); + st.ok(fromReduce.constructor.name === 'Array'); + st.ok(fromReduce.length === list.length); + st.match(fromReduce, list); + st.match(JSON.stringify([...list]), JSON.stringify(fromReduce)); + + // reduceRight() + const fromReduceRight = list.reduceRight((acc, el) => acc.concat(el), []); + st.ok(fromReduceRight.constructor.name === 'Array'); + st.ok(fromReduceRight.length === list.length); + st.match(fromReduceRight.reverse(), list); + st.match(JSON.stringify([...list]), JSON.stringify(fromReduceRight)); + + // flat() + const fromFlat = list.flat(); + st.ok(fromFlat.constructor.name === 'Array'); + st.ok(fromFlat.length === list.length); + st.match(fromFlat, list); + st.match(JSON.stringify([...list]), JSON.stringify(fromFlat)); + + // flatMap() + const fromFlatMap = list.flatMap((el) => [el]); + st.ok(fromFlatMap.constructor.name === 'Array'); + st.ok(fromFlatMap.length === list.length); + st.match(fromFlatMap, list); + st.match(JSON.stringify([...list]), JSON.stringify(fromFlatMap)); + + // toSpliced() + const fromSpliced = list.toSpliced(0, 0); + st.ok(fromSpliced.constructor.name === 'Array'); + st.ok(fromSpliced.length === list.length); + st.match(fromSpliced, list); + st.match(JSON.stringify([...list]), JSON.stringify(fromSpliced)); + + // with() + if (values.length > 0) { + const fromWith = list.with(0, 'a'); + st.ok(fromWith.constructor.name === 'Array'); + st.ok(fromWith.length === list.length); + st.notMatch(fromWith, list); + st.notMatch(fromWith, values); + st.match([...fromWith.slice(1)], values.slice(1)); + st.match(fromWith.concat(...list), fromWith); } else { - st.throws( - () => list.with(0, 'a').slice(1), - new RangeError('Incorrect index'), - ); + if (!process.version.match(/^v1[2-8]\./)) { + st.throws(() => list.with(0, 'a'), new Error('Invalid index : 0')); + } else { + st.throws( + () => list.with(0, 'a').slice(1), + new RangeError('Incorrect index'), + ); + } } - } -}; - -t.test('empty stringList', (t) => { - const list = stringList(); - t.match([...list], []); - testExpectedArrayValues(t, list); - testEscapingFromStringList(t, list); - t.doesNotThrow(() => { - t.ok(list.concat().length === 0); - t.ok(list.withPrefix('.').length === 0); - t.ok(list.withSuffix('.').length === 0); - t.ok(list.toSorted().length === 0); - t.ok(list.toReversed().length === 0); + }; + + t.test(type + ': empty stringList', (t) => { + const list = stringList(); + t.match([...list], []); + testExpectedArrayValues(t, list); + testEscapingFromStringList(t, list); + t.doesNotThrow(() => { + t.ok(list.concat().length === 0); + t.ok(list.withPrefix('.').length === 0); + t.ok(list.withSuffix('.').length === 0); + t.ok(list.toSorted().length === 0); + t.ok(list.toReversed().length === 0); + }); + const notEmpty = list.concat('a', 'b', 'c'); + t.ok(notEmpty.length === 3); + t.ok(list.length === 0); + t.notMatch(list, notEmpty); + t.match(notEmpty, stringList('a', 'b', 'c')); + + t.end(); + }); + + t.test(type + ': enum object', (t) => { + const list = stringList('foo', 'bar'); + t.match(list.enum, { + foo: 'foo', + bar: 'bar', + }); + + const list2 = list.concat('doink', 'bleep'); + + t.match(list.enum, { + foo: 'foo', + bar: 'bar', + }); + + t.match(list2.enum, { + foo: 'foo', + bar: 'bar', + doink: 'doink', + bleep: 'bleep', + }); + t.end(); + }); + + t.test(type + ": stringList('foo')", (t) => { + const list = stringList('foo'); + testExpectedArrayValues(t, list, 'foo'); + testEscapingFromStringList(t, list, 'foo'); + t.end(); + }); + + t.test(type + ": stringList('foo', 'bar')", (t) => { + const list = stringList('foo', 'bar'); + testExpectedArrayValues(t, list, 'foo', 'bar'); + testEscapingFromStringList(t, list, 'foo', 'bar'); + t.end(); }); - const notEmpty = list.concat('a', 'b', 'c'); - t.ok(notEmpty.length === 3); - t.ok(list.length === 0); - t.notMatch(list, notEmpty); - t.match(notEmpty, stringList('a', 'b', 'c')); - - t.end(); -}); - -t.test('enum object', (t) => { - const list = stringList('foo', 'bar'); - t.match(list.enum, { - foo: 'foo', - bar: 'bar', + + t.test(type + ": withPrefix('prefix.')", (t) => { + const list = stringList('foo', 'bar').withPrefix('prefix.'); + testExpectedArrayValues(t, list, 'prefix.foo', 'prefix.bar'); + testEscapingFromStringList(t, list, 'prefix.foo', 'prefix.bar'); + t.end(); }); - const list2 = list.concat('doink', 'bleep'); + t.test(type + ": withDerivatedSuffix('s')", (t) => { + const list = stringList('food', 'bars', 'pasta', 'meatballs') + .withDerivatedSuffix('s') + .toSorted((a, b) => a.localeCompare(b)); + testExpectedArrayValues( + t, + list, + 'bar', + 'bars', + 'food', + 'foods', + 'meatball', + 'meatballs', + 'pasta', + 'pastas', + ); + testEscapingFromStringList( + t, + list, + 'bar', + 'bars', + 'food', + 'foods', + 'meatball', + 'meatballs', + 'pasta', + 'pastas', + ); + t.end(); + }); + + t.test(type + ": withDerivatedPrefix('#')", (t) => { + const list = stringList('#trending', 'stuff') + .withDerivatedPrefix('#') + .toSorted((a, b) => a.localeCompare(b)); + testExpectedArrayValues( + t, + list, + '#stuff', + '#trending', + 'stuff', + 'trending', + ); + testEscapingFromStringList( + t, + list, + '#stuff', + '#trending', + 'stuff', + 'trending', + ); + t.end(); + }); + + t.test(type + ": withSuffix('.suffix')", (t) => { + const list = stringList('foo', 'bar').withSuffix('.suffix'); + testExpectedArrayValues(t, list, 'foo.suffix', 'bar.suffix'); + testEscapingFromStringList(t, list, 'foo.suffix', 'bar.suffix'); + t.end(); + }); + + t.test(type + ': withReplace("1")', (t) => { + const list = stringList('f1oo', 'b1ar').withReplace('1', ''); + testExpectedArrayValues(t, list, 'foo', 'bar'); + testEscapingFromStringList(t, list, 'foo', 'bar'); + t.end(); + }); - t.match(list.enum, { - foo: 'foo', - bar: 'bar', + t.test(type + ': withReplaceAll("z")', (t) => { + const list = stringList('foo', 'azzztiv', 'zzz', 'z1').withReplaceAll( + 'z', + '', + ); + testExpectedArrayValues(t, list, 'foo', 'ativ', '', '1'); + testEscapingFromStringList(t, list, 'foo', 'ativ', '', '1'); + t.end(); }); - t.match(list2.enum, { - foo: 'foo', - bar: 'bar', - doink: 'doink', - bleep: 'bleep', + t.test(type + ': withTrim()', (t) => { + const list = stringList(' foo ', ' bar ').withTrim(); + testExpectedArrayValues(t, list, 'foo', 'bar'); + testEscapingFromStringList(t, list, 'foo', 'bar'); + t.end(); }); - t.end(); -}); - -t.test("stringList('foo')", (t) => { - const list = stringList('foo'); - testExpectedArrayValues(t, list, 'foo'); - testEscapingFromStringList(t, list, 'foo'); - t.end(); -}); - -t.test("stringList('foo', 'bar')", (t) => { - const list = stringList('foo', 'bar'); - testExpectedArrayValues(t, list, 'foo', 'bar'); - testEscapingFromStringList(t, list, 'foo', 'bar'); - t.end(); -}); - -t.test("withPrefix('prefix.')", (t) => { - const list = stringList('foo', 'bar').withPrefix('prefix.'); - testExpectedArrayValues(t, list, 'prefix.foo', 'prefix.bar'); - testEscapingFromStringList(t, list, 'prefix.foo', 'prefix.bar'); - t.end(); -}); - -t.test("withDerivatedSuffix('s')", (t) => { - const list = stringList('food', 'bars', 'pasta', 'meatballs') - .withDerivatedSuffix('s') - .toSorted((a, b) => a.localeCompare(b)); - testExpectedArrayValues( - t, - list, - 'bar', - 'bars', - 'food', - 'foods', - 'meatball', - 'meatballs', - 'pasta', - 'pastas', - ); - testEscapingFromStringList( - t, - list, - 'bar', - 'bars', - 'food', - 'foods', - 'meatball', - 'meatballs', - 'pasta', - 'pastas', - ); - t.end(); -}); - -t.test("withDerivatedPrefix('#')", (t) => { - const list = stringList('#trending', 'stuff') - .withDerivatedPrefix('#') - .toSorted((a, b) => a.localeCompare(b)); - testExpectedArrayValues(t, list, '#stuff', '#trending', 'stuff', 'trending'); - testEscapingFromStringList( - t, - list, - '#stuff', - '#trending', - 'stuff', - 'trending', - ); - t.end(); -}); - -t.test("withSuffix('.suffix')", (t) => { - const list = stringList('foo', 'bar').withSuffix('.suffix'); - testExpectedArrayValues(t, list, 'foo.suffix', 'bar.suffix'); - testEscapingFromStringList(t, list, 'foo.suffix', 'bar.suffix'); - t.end(); -}); - -t.test('withReplace("1")', (t) => { - const list = stringList('f1oo', 'b1ar').withReplace('1', ''); - testExpectedArrayValues(t, list, 'foo', 'bar'); - testEscapingFromStringList(t, list, 'foo', 'bar'); - t.end(); -}); - -t.test('withReplaceAll("z")', (t) => { - const list = stringList('foo', 'azzztiv', 'zzz', 'z1').withReplaceAll( - 'z', - '', - ); - testExpectedArrayValues(t, list, 'foo', 'ativ', '', '1'); - testEscapingFromStringList(t, list, 'foo', 'ativ', '', '1'); - t.end(); -}); - -t.test('withTrim()', (t) => { - const list = stringList(' foo ', ' bar ').withTrim(); - testExpectedArrayValues(t, list, 'foo', 'bar'); - testEscapingFromStringList(t, list, 'foo', 'bar'); - t.end(); -}); - -t.test('withTrim().withReplaceAll("_")', (t) => { - const list = stringList('has spaces ', ' has more_spaces') - .withTrim() - .withReplaceAll(' ', '_'); - testExpectedArrayValues(t, list, 'has_spaces', 'has_more_spaces'); - testEscapingFromStringList(t, list, 'has_spaces', 'has_more_spaces'); - t.end(); -}); - -t.test('without()', (t) => { - const list = stringList('foo', 'bar').without('bar'); - testExpectedArrayValues(t, list, 'foo'); - testEscapingFromStringList(t, list, 'foo'); - - const list2 = stringList( - 'foo', - 'bar', - 'bar2', - 'foo2', - 'bar3', - 'foo3', - 'bar4', - 'foo4', - ).without( - stringList('bar', 'foo'), - // @ts-expect-error (because of null/object in the parameters - added to cover the case) - 'bar2', - stringList('bar3', 'foo3'), - 'foo4', - null, - { foo2: 'bar4' }, - ); - testExpectedArrayValues(t, list2, 'foo2', 'bar4'); - testEscapingFromStringList(t, list2, 'foo2', 'bar4'); - t.end(); -}); - -t.test("concat('zing', 'boom')", (t) => { - const list = stringList('foo', 'bar').concat('zing', 'boom'); - testExpectedArrayValues(t, list, 'foo', 'bar', 'zing', 'boom'); - testEscapingFromStringList(t, list, 'foo', 'bar', 'zing', 'boom'); - t.end(); -}); - -t.test("concat(stringList, stringList, 'a', 'b', 'c', 'd')", (t) => { - const a = stringList('abc', 'def', 'ghi'); - const b = stringList('jkl', 'mno', 'pqr'); - const c = stringList('stu', 'vwx', 'yz'); - const list = a.concat(b, c, 'a', 'b', 'c', 'd'); - testExpectedArrayValues( - t, - list, - 'abc', - 'def', - 'ghi', - 'jkl', - 'mno', - 'pqr', - 'stu', - 'vwx', - 'yz', - 'a', - 'b', - 'c', - 'd', - ); - testEscapingFromStringList( - t, - list, - 'abc', - 'def', - 'ghi', - 'jkl', - 'mno', - 'pqr', - 'stu', - 'vwx', - 'yz', - 'a', - 'b', - 'c', - 'd', - ); - t.end(); -}); - -t.test('toSorted()', (t) => { - const list = stringList('foo', 'bar').toSorted(); - testExpectedArrayValues(t, list, 'bar', 'foo'); - testEscapingFromStringList(t, list, 'bar', 'foo'); - t.end(); -}); - -t.test('toReversed()', (t) => { - const list = stringList('foo', 'bar').toReversed(); - testExpectedArrayValues(t, list, 'bar', 'foo'); - testEscapingFromStringList(t, list, 'bar', 'foo'); - t.end(); -}); - -t.test('slice()', (t) => { - const list = stringList('foo', 'bar', 'baz').slice(1, 3); - testExpectedArrayValues(t, list, 'bar', 'baz'); - testEscapingFromStringList(t, list, 'bar', 'baz'); - t.end(); -}); - -t.test('all chained', (t) => { - const list = stringList('foo', 'bar') - .concat('doink', 'bleep') - .withPrefix('prefix.') - .withSuffix('.suffix') - .toSorted() - .toReversed(); - testExpectedArrayValues( - t, - list, - 'prefix.foo.suffix', - 'prefix.doink.suffix', - 'prefix.bleep.suffix', - 'prefix.bar.suffix', - ); - testEscapingFromStringList( - t, - list, - 'prefix.foo.suffix', - 'prefix.doink.suffix', - 'prefix.bleep.suffix', - 'prefix.bar.suffix', - ); - t.end(); -}); - -t.test('stringList(invalid arguments) throws', (t) => { - t.doesNotThrow( - // @ts-expect-error[incompatible-call] - () => stringList(4, 'foo', ['d', 45], undefined), - ); - - t.end(); -}); - -t.test('stringList mutable', (t) => { - const list = stringList('foo'); + + t.test(type + ': withTrim().withReplaceAll("_")', (t) => { + const list = stringList('has spaces ', ' has more_spaces') + .withTrim() + .withReplaceAll(' ', '_'); + testExpectedArrayValues(t, list, 'has_spaces', 'has_more_spaces'); + testEscapingFromStringList(t, list, 'has_spaces', 'has_more_spaces'); + t.end(); + }); + + t.test(type + ': without()', (t) => { + const list = stringList('foo', 'bar').without('bar'); + testExpectedArrayValues(t, list, 'foo'); + testEscapingFromStringList(t, list, 'foo'); + + const list2 = stringList( + 'foo', + 'bar', + 'bar2', + 'foo2', + 'bar3', + 'foo3', + 'bar4', + 'foo4', + ).without( + stringList('bar', 'foo'), + // @ts-expect-error (because of null/object in the parameters - added to cover the case) + 'bar2', + stringList('bar3', 'foo3'), + 'foo4', + null, + { foo2: 'bar4' }, + ); + testExpectedArrayValues(t, list2, 'foo2', 'bar4'); + testEscapingFromStringList(t, list2, 'foo2', 'bar4'); + t.end(); + }); + + t.test(type + ": concat('zing', 'boom')", (t) => { + const list = stringList('foo', 'bar').concat('zing', 'boom'); + testExpectedArrayValues(t, list, 'foo', 'bar', 'zing', 'boom'); + testEscapingFromStringList(t, list, 'foo', 'bar', 'zing', 'boom'); + t.end(); + }); + + t.test(type + ": concat(stringList, stringList, 'a', 'b', 'c', 'd')", (t) => { + const a = stringList('abc', 'def', 'ghi'); + const b = stringList('jkl', 'mno', 'pqr'); + const c = stringList('stu', 'vwx', 'yz'); + const list = a.concat(b, c, 'a', 'b', 'c', 'd'); + testExpectedArrayValues( + t, + list, + 'abc', + 'def', + 'ghi', + 'jkl', + 'mno', + 'pqr', + 'stu', + 'vwx', + 'yz', + 'a', + 'b', + 'c', + 'd', + ); + testEscapingFromStringList( + t, + list, + 'abc', + 'def', + 'ghi', + 'jkl', + 'mno', + 'pqr', + 'stu', + 'vwx', + 'yz', + 'a', + 'b', + 'c', + 'd', + ); + t.end(); + }); + + t.test(type + ': toSorted()', (t) => { + const list = stringList('foo', 'bar').toSorted(); + testExpectedArrayValues(t, list, 'bar', 'foo'); + testEscapingFromStringList(t, list, 'bar', 'foo'); + t.end(); + }); + + t.test(type + ': toReversed()', (t) => { + const list = stringList('foo', 'bar').toReversed(); + testExpectedArrayValues(t, list, 'bar', 'foo'); + testEscapingFromStringList(t, list, 'bar', 'foo'); + t.end(); + }); + + t.test(type + ': slice()', (t) => { + const list = stringList('foo', 'bar', 'baz').slice(1, 3); + testExpectedArrayValues(t, list, 'bar', 'baz'); + testEscapingFromStringList(t, list, 'bar', 'baz'); + t.end(); + }); + + t.test(type + ': all chained', (t) => { + const list = stringList('foo', 'bar') + .concat('doink', 'bleep') + .withPrefix('prefix.') + .withSuffix('.suffix') + .toSorted() + .toReversed(); + testExpectedArrayValues( + t, + list, + 'prefix.foo.suffix', + 'prefix.doink.suffix', + 'prefix.bleep.suffix', + 'prefix.bar.suffix', + ); + testEscapingFromStringList( + t, + list, + 'prefix.foo.suffix', + 'prefix.doink.suffix', + 'prefix.bleep.suffix', + 'prefix.bar.suffix', + ); + t.end(); + }); + + t.test(type + ': stringList(invalid arguments) throws', (t) => { + t.doesNotThrow(() => stringList(4, 'foo', ['d', 45], undefined)); + + t.end(); + }); + Object.values(ARRAY_IN_PLACE_MUTATION).forEach((el) => { - t.doesNotThrow(() => { - // @ts-expect-error - list[el](0); + t.test(type + `: stringList calling function list.${el}()`, (t) => { + const list = stringList('foo', 'bar'); + if (el !== 'pop') { + t.end(); + return; + } + if (type === 'immutable') { + t.throws( + () => + // @ts-expect-error + list[el](0, 0), + `foo'.${el} should throw in ${type} mode`, + ); + } else { + t.doesNotThrow( + () => + // @ts-expect-error + list[el](0, 0), + `foo'.${el} should fail`, + ); + } + t.ok( + typeof list.mutable()[el] === 'function', + `Expected list.mutable().${el} to exists.`, + ); + t.ok( + // @ts-expect-error + typeof list.mutable()[el](0, 0) !== 'undefined', + `Expected list.mutable().${el} to exists and return something.`, + ); + t.end(); }); + }); + + t.test(type + ': search methods', (t) => { + const values = 'abcdefghij'.split(''); + const list = stringList('foo', 'bar', 'baz') + .withPrefix('a.') + .withSuffix('.z'); + + t.ok(!list.includes('bar')); + t.ok(!list.includes(values[1])); + t.ok(!list.includes(null)); + t.ok(!list.includes(undefined)); + t.ok(!list.includes(0)); + t.ok(!list.includes(/foo/i)); + t.ok(!list.some((el) => el === values[1])); + t.ok(!list.some((el) => el === null)); + t.ok(!list.some((el) => el === undefined)); + // @ts-expect-error + t.ok(!list.some((el) => el === 0)); + t.ok(list.indexOf('bar') === -1); + t.ok(list.indexOf(null) === -1); + t.ok(list.indexOf(undefined) === -1); + t.ok(list.indexOf(values[1]) === -1); + t.ok(!list.every((el) => el === values[1])); + t.ok(!list.every((e) => e === null)); + t.ok(!list.every((e) => e === undefined)); // @ts-expect-error - t.ok(list.mutable()[el]()); + t.ok(!list.every((e) => e === 0)); + t.ok(!list.find((el) => el === values[1])); + t.ok(!list.find((el) => el === null)); + t.ok(!list.find((el) => el === undefined)); + // @ts-expect-error + t.ok(!list.find((el) => el === 0)); + t.ok(list.findIndex((el) => el === values[1]) === -1); + t.ok(list.findIndex((el) => el === null) === -1); + t.ok(list.findIndex((el) => el === undefined) === -1); + // @ts-expect-error + t.ok(list.findIndex((el) => el === 0) === -1); + + t.end(); }); - t.end(); -}); - -t.test('search methods', (t) => { - const values = 'abcdefghij'.split(''); - const list = stringList('foo', 'bar', 'baz') - .withPrefix('a.') - .withSuffix('.z'); - - t.ok(!list.includes('bar')); - t.ok(!list.includes(values[1])); - t.ok(!list.includes(null)); - t.ok(!list.includes(undefined)); - t.ok(!list.includes(0)); - t.ok(!list.includes(/foo/i)); - t.ok(!list.some((el) => el === values[1])); - t.ok(!list.some((el) => el === null)); - t.ok(!list.some((el) => el === undefined)); - // @ts-expect-error - t.ok(!list.some((el) => el === 0)); - t.ok(list.indexOf('bar') === -1); - t.ok(list.indexOf(null) === -1); - t.ok(list.indexOf(undefined) === -1); - t.ok(list.indexOf(values[1]) === -1); - t.ok(!list.every((el) => el === values[1])); - t.ok(!list.every((e) => e === null)); - t.ok(!list.every((e) => e === undefined)); - // @ts-expect-error - t.ok(!list.every((e) => e === 0)); - t.ok(!list.find((el) => el === values[1])); - t.ok(!list.find((el) => el === null)); - t.ok(!list.find((el) => el === undefined)); - // @ts-expect-error - t.ok(!list.find((el) => el === 0)); - t.ok(list.findIndex((el) => el === values[1]) === -1); - t.ok(list.findIndex((el) => el === null) === -1); - t.ok(list.findIndex((el) => el === undefined) === -1); - // @ts-expect-error - t.ok(list.findIndex((el) => el === 0) === -1); - - t.end(); -}); +} diff --git a/stringListFunction.d.ts b/stringListFunction.d.ts new file mode 100644 index 0000000..26d57e9 --- /dev/null +++ b/stringListFunction.d.ts @@ -0,0 +1,5 @@ +import { IStringList } from './StringLiteralList.js'; + +export function stringListMutable(...list: TT): IStringList; + +export function stringListReadonly(...list: TT): IStringList; diff --git a/stringListFunction.js b/stringListFunction.js new file mode 100644 index 0000000..efbb272 --- /dev/null +++ b/stringListFunction.js @@ -0,0 +1,36 @@ +// @ts-check +/// + +import { SL } from './StringLiteralList.js'; + +export function stringListMutable(...strings) { + let values = strings; + let invalid = strings.some((el) => typeof el !== 'string'); + if (strings.length && invalid) { + /* c8 ignore start */ + if ( + typeof window === 'undefined' && + process?.env?.NODE_ENV !== 'production' && + process?.env?.NODE_ENV !== 'test' + ) { + console.debug( + `Unexpected type in stringList(${typeof invalid}). Casting all arguments to string type.`, + ); + } + /* c8 ignore stop */ + values = strings.flatMap((el) => + Array.isArray(el) + ? el.filter((s) => typeof s === 'string') + : typeof el === 'string' + ? [el] + : typeof el === 'number' + ? [String(el)] + : [], + ); + } + return new SL(...values); +} + +export function stringListReadonly(...strings) { + return Object.freeze(stringListMutable(...strings)); +} diff --git a/types.d.ts b/types.d.ts index 9a88c14..b8bc59d 100644 --- a/types.d.ts +++ b/types.d.ts @@ -5,8 +5,51 @@ export namespace sl { /** * @credit @gustavoguichard */ + /** + * Returns true if input number type is a literal + */ + type IsNumberLiteral = [T] extends [number] + ? [number] extends [T] + ? false + : true + : false; + + type IsBooleanLiteral = [T] extends [boolean] + ? [boolean] extends [T] + ? false + : true + : false; + + /** + * Returns true if any elements in boolean array are the literal true (not false or boolean) + */ + type Any = Arr extends [ + infer Head extends boolean, + ...infer Rest extends boolean[], + ] + ? IsBooleanLiteral extends true + ? Head extends true + ? true + : Any + : Any + : false; + + /** + * Returns true if every element in boolean array is the literal true (not false or boolean) + */ + type All = IsBooleanLiteral extends true + ? Arr extends [infer Head extends boolean, ...infer Rest extends boolean[]] + ? Head extends true + ? Any + : false // Found `false` in array + : true // Empty array (or all elements have already passed test) + : false; // Array/Tuple contains `boolean` type + type IsStringLiteral = [T] extends [string] ? [string] extends [T] ? false : Uppercase extends Uppercase> ? Lowercase extends Lowercase> ? true : false : false : false; + type IsStringLiteralArray = + IsStringLiteral extends true ? true : false; + export type StringConcat< T1 extends string | number | bigint | boolean, T2 extends string | number | bigint | boolean, @@ -40,6 +83,20 @@ export namespace sl { type Replace = lookup extends string ? IsStringLiteral extends true ? sentence extends `${infer rest}${lookup}${infer rest2}` ? `${rest}${replacement}${rest2}` : sentence : string : string; type ReplaceAll = lookup extends string ? IsStringLiteral extends true ? sentence extends `${infer rest}${lookup}${infer rest2}` ? `${rest}${replacement}${ReplaceAll}` : sentence : string : string; + + type Join< + T extends readonly string[], + delimiter extends string = '', + > = All<[IsStringLiteralArray, IsStringLiteral]> extends true + ? T extends readonly [ + infer first extends string, + ...infer rest extends string[], + ] + ? rest extends [] + ? first + : `${first}${delimiter}${Join}` + : '' + : string } export namespace specs { @@ -97,7 +154,6 @@ export namespace sl { */ export type NativeMethod = | 'join' - | 'slice' | 'toLocaleString' | 'toString' | 'entries'