Skip to content

Commit

Permalink
Merge pull request #2 from rawpixel-vincent/update-concat
Browse files Browse the repository at this point in the history
allow list or string in concat arguments
  • Loading branch information
rawpixel-vincent authored Mar 3, 2024
2 parents dfd40fa + 126bc71 commit 86d79ad
Show file tree
Hide file tree
Showing 11 changed files with 282 additions and 146 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:

strategy:
matrix:
node-version: ['20', 'latest']
node-version: ['16', '18', '20', 'latest']
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
Expand Down
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npm test
176 changes: 96 additions & 80 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,42 @@
# Array for string literals

For runtime type safe array of string literal without compilation.
Typed array of string literals working at runtime without typescript compilation.

Useful for types constructs that can be used at runtime.

*If you code in typescript, you probably don't need any of this.*

## Why?
## Overview

The javascript Array interface is not designed to work with constant string literal.
The StringList class extends the Array interface types to work with string literals.

The methods like concat, or includes will expect only the constants as argument, which makes a method like includes() useless, and others build method annoying to type when constructing the constants.
- immutable: methods that mutate the array in place like push, pop, shift, unshift, splice are omitted.

### The js workarounds
- inference: concat, toReversed and toSorted 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.

- includes(): using mapped object in your code instead of the array, e.g. `!!MY_LIST_AS_unnecessary_MAP[val]`, but if you want type safety this means no .concat(), no .includes(), no iteration without creating new variables.
- concat(): just concatenate your workarounds into a single type. e.g. `/** @type {((keyof typeof MAP_A) | MAPPED_FROM_MAP_B)[]} */` not solving any issues with the underlying unusable array methods.
- search methods: includes, indexOf, find, every, .. and others search methods types are updated to prevent type errors when comparing with non-literals strings.

### Code demonstrating the problem
- not chainable or iterator: map / filter / reduce / flat / values / keys / entries are the same as the native Array, and will return the type defined by the native method.

```js
// Not typed.
const arr = ['foo', 'bar'];
/// const arr: string[]
arr.push('d'); // OK
arr.includes('zing'); // OK
arr.concat(['zing']); // OK
- no changes is made to the execution of the methods to minimize maintenance, bugs or breaking changes. Types on the other hand are subjects to bugs and breaking changes, or incompatibilities with typescript and es versions.

// Typed
const O = {
foo: 'foo',
bar: 'bar',
};
/** @type { (keyof typeof O)[] } */
// @ts-expect-error "Type 'string' is not assignable to type '"foo" | "bar"'
const lit = Object.keys(O);
// or just
/** @type {('foo' | 'bar')[]} */
const lit = new Array('foo', 'bar'); // OK
// const lit: ('foo' | 'bar')[]
- `mutable():string[]` has been implemented to return a copy of the underlying array as string[].

// Then

lit.includes('asd');
// Argument of type '"asd"' is not assignable to parameter of type '"foo" | "bar"'.ts(2345)
let val = 'asd';
lit.includes(val);
// Argument of type 'string' is not assignable to parameter of type '"foo" | "bar"'.ts(2345)
lit.concat([val]);
// No overload matches this call.
// Overload 1 of 2, '(...items: ConcatArray<"foo" | "bar">[]): ("foo" | "bar")[]', gave the following error.
// Type 'string' is not assignable to type '"foo" | "bar"'.
// 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">'.
// Type 'string[]' is not assignable to type 'ConcatArray<"foo" | "bar">'.
// The types returned by 'slice(...)' are incompatible between these types.
// Type 'string[]' is not assignable to type '("foo" | "bar")[]'.
// Type 'string' is not assignable to type '"foo" | "bar"'.ts(2769)
lit.concat(val);
// No overload matches this call.
// Overload 1 of 2, '(...items: ConcatArray<"foo" | "bar">[]): ("foo" | "bar")[]', gave the following error.
// Argument of type 'string' is not assignable to parameter of type 'ConcatArray<"foo" | "bar">'.
// 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)
```

## The "solution"

stringList extends the native Array types to works with string literals.
it omits the methods that mutate the array in place like push, pop, shift, unshift, splice.

### Overview
- more methods relevants to string literals and type constructs could be added along the way.

```js
import { stringList } from 'string-literal-list';

let v = stringList("foo", "bar", ...) => StringList<"foo" | "bar">;
let v = stringList("foo", "bar", ...) => SL<"foo" | "bar">;

v.includes(anyValue) => boolean;

v.withPrefix('prefix.') => StringList<"prefix.foo" | "prefix.bar">
v.withPrefix('prefix.') => SL<"prefix.foo" | "prefix.bar">

v.withSuffix('.suffix') => StringList<"foo.suffix" | "bar.suffix">
v.withSuffix('.suffix') => SL<"foo.suffix" | "bar.suffix">

v.concat('zing', 'boom') => StringList<"foo" | "bar" | "zing" | "boom">
v.concat('zing', 'boom', stringList('zig', 'zag')) => SL<"foo" | "bar" | "zing" | "boom" | "zig" | "zag">;

```

Expand All @@ -104,37 +59,39 @@ const list = stringList(
'foo',
'bar',
);
// literals types are inferred
// list: stringList<"foo" | "bar">;
// SL<"foo" | "bar">;

const prefixed = list.withPrefix('prefix.');
// stringList<"prefix.foo" | "prefix.bar">;
// SL<"prefix.foo" | "prefix.bar">;

const suffixed = list.withSuffix('.suffix');
// stringList<"foo.suffix" | "bar.suffix">;
// SL<"foo.suffix" | "bar.suffix">;

const concat = prefixed.concat(...suffixed);
// stringList<"prefix.foo" | "prefix.bar" | "foo.suffix" | "bar.suffix">;
// SL<"prefix.foo" | "prefix.bar" | "foo.suffix" | "bar.suffix">;

const bothWay = list.withPrefix('data.').withSuffix('.ext');
// stringList<"data.foo.ext" | "data.bar.ext">;
// SL<"data.foo.ext" | "data.bar.ext">;

/** @type {any|unknown|'notInTheList'} */
let val;
list.includes(val); // No type error just boolean result.
// list implements similar fix for indexOf, lastIndexOf, filter, some, every, findIndex and find methods.
```

#### list.concat vs array.concat
#### list.concat(...(string|StringList)[])

list.concat require string literals arguments to enable inference.
`list.concat()` accept only string and StringList as arguments to enable inference.
If a native array is passed the string literals won't be inferred.

```js
// OK
list.concat('zing', 'foo');
list.concat('zing', 'foo', stringList('gurgle', 'doink'));
=> SL<"foo" | "bar" | 'zing' | 'gurgle' | 'doink'>

// ERROR
list.concat(['zing', 'foo']);
// Not OK.
list.concat('zing', 'foo', ['boom', 'bar']);
// => Argument of type '"foo"' is not assignable to parameter of type '"zing" | ILiterals<"zing">'.ts(2345)
```

### filter / map / reduce and other array methods
Expand All @@ -161,8 +118,8 @@ namespace specs {

/**
* @description
* These methods are implemented in StringList class to change the returned type to IStringList.
* The execution is delegated to the Array instance and the result is used to construct the returned IStringList.
* The execution is delegated to the Array instance methods.
* The implementation uses the result to return a new readonly list instance.
*/
type ImplementedMethod =
| 'concat'
Expand All @@ -173,7 +130,7 @@ namespace specs {

/**
* @description
* These methods only get a type override to fix the comparison between `T` and `string`.
* The type of these methods are updated to fix ts value comparison between `T` and `string`.
*/
type NativeMethodWithTypeOverride =
| 'at'
Expand All @@ -186,13 +143,13 @@ namespace specs {
| 'findIndex'
| 'some'
| 'every'
| 'filter';
| 'filter'
| 'with';

/**
* @description
* These methods are the same are coming from the Array instance.
* No type overrides here.
* They will return the original type, (e.g. mutable array in case of map / reduce and other transforming methods.)
* No type or any overrides.
* filter, map, flatMap, flat will result in the original array type.
*/
type NativeMethod =
| 'join'
Expand All @@ -209,3 +166,62 @@ namespace specs {
| 'flatMap';
}
```

## Why?

The javascript Array interface is not designed to work with constant string literal.

The methods like concat, or includes will expect only the constants as argument, which makes a method like includes() useless, and others build method annoying to type when constructing the constants.

### workarounds

- includes(): using mapped object in your code instead of the array, e.g. `!!MY_LIST_AS_unnecessary_MAP[val]`, but if you want type safety this means no .concat(), no .includes(), no iteration without creating new variables.
- concat(): just concatenate your workarounds into a single type. e.g. `/** @type {((keyof typeof MAP_A) | MAPPED_FROM_MAP_B)[]} */` not solving any issues with the underlying unusable array methods.

### Code showing the problem using typed array

```js
// Not typed.
const arr = ['foo', 'bar'];
/// const arr: string[]
arr.push('d'); // OK
arr.includes('zing'); // OK
arr.concat(['zing']); // OK

// Typed
const O = {
foo: 'foo',
bar: 'bar',
};
/** @type { (keyof typeof O)[] } */
// @ts-expect-error "Type 'string' is not assignable to type '"foo" | "bar"'
const lit = Object.keys(O);
// or just
/** @type {('foo' | 'bar')[]} */
const lit = new Array('foo', 'bar'); // OK
// const lit: ('foo' | 'bar')[]

// Then

lit.includes('asd');
// Argument of type '"asd"' is not assignable to parameter of type '"foo" | "bar"'.ts(2345)
let val = 'asd';
lit.includes(val);
// Argument of type 'string' is not assignable to parameter of type '"foo" | "bar"'.ts(2345)
lit.concat([val]);
// No overload matches this call.
// Overload 1 of 2, '(...items: ConcatArray<"foo" | "bar">[]): ("foo" | "bar")[]', gave the following error.
// Type 'string' is not assignable to type '"foo" | "bar"'.
// 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">'.
// Type 'string[]' is not assignable to type 'ConcatArray<"foo" | "bar">'.
// The types returned by 'slice(...)' are incompatible between these types.
// Type 'string[]' is not assignable to type '("foo" | "bar")[]'.
// Type 'string' is not assignable to type '"foo" | "bar"'.ts(2769)
lit.concat(val);
// No overload matches this call.
// Overload 1 of 2, '(...items: ConcatArray<"foo" | "bar">[]): ("foo" | "bar")[]', gave the following error.
// Argument of type 'string' is not assignable to parameter of type 'ConcatArray<"foo" | "bar">'.
// 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)
```
14 changes: 9 additions & 5 deletions StringLiteralList.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { sl } from './types.js';

interface ILiterals<T extends unknown = null> {
readonly literal: T;
}

export interface IStringList<T extends unknown>
extends Omit<
Array<T>,
| sl.specs.ImplementedMethod
| sl.specs.OmitedMutableMethod
| sl.specs.NativeMethodWithTypeOverride
> {
>, ILiterals<T> {
// Custom Methods
withPrefix<P extends string>(
prefix: P,
Expand All @@ -19,9 +23,8 @@ export interface IStringList<T extends unknown>
// Implemented methods to return the frozen array, typed as IStringList.
toSorted(compareFn?: (a: T, b: T) => number): IStringList<T>;
toReversed(): IStringList<T>;
concat<PP extends T, P extends string = string>(
...arg: P[]
): IStringList<Record<P, P>[keyof Record<P, P>] | PP>;

concat<PP extends T, S extends string>(...arg: (ILiterals<S> | S)[]): Readonly<IStringList<PP | S>>;

// Readonly overrides
readonly length: number;
Expand Down Expand Up @@ -68,6 +71,7 @@ export interface IStringList<T extends unknown>
): boolean;

// Return Type overrides
with(index: number, value: any): string[];
filter<S extends T>(
predicate: (value: T, index: number, array: IStringList<T>) => value is S,
thisArg?: any,
Expand All @@ -79,7 +83,7 @@ export interface IStringList<T extends unknown>
toSpliced(start: number, deleteCount: number, ...items: string[]): string[];
}

export class StringLiteralList<T extends string> {
export class SL<T extends string> {
constructor(...list: T[]);
}

Expand Down
36 changes: 18 additions & 18 deletions StringLiteralList.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,28 @@
export class StringLiteralList extends Array {
concat() {
return Object.freeze(
new StringLiteralList(...super.concat.apply(this, arguments)),
);
import 'core-js/actual/array/to-reversed.js';
import 'core-js/actual/array/to-sorted.js';
import 'core-js/actual/array/to-spliced.js';
import 'core-js/actual/array/with.js';

export class SL extends Array {
internal = undefined;
concat(...args) {
return Object.freeze(new SL(...super.concat.apply(this, args.flat())));
}

toSorted() {
return Object.freeze(
new StringLiteralList(...super.toSorted.apply(this, arguments)),
);
return Object.freeze(new SL(...super.toSorted.apply(this, arguments)));
}

toReversed() {
return Object.freeze(
new StringLiteralList(...super.toReversed.apply(this, arguments)),
);
return Object.freeze(new SL(...super.toReversed.apply(this, arguments)));
}

withPrefix(prefix) {
return Object.freeze(
new StringLiteralList(...super.map((e) => `${prefix}${e}`)),
);
return Object.freeze(new SL(...super.map((e) => `${prefix}${e}`)));
}

withSuffix(suffix) {
return Object.freeze(
new StringLiteralList(...super.map((e) => `${e}${suffix}`)),
);
return Object.freeze(new SL(...super.map((e) => `${e}${suffix}`)));
}

// Get the native array
Expand Down Expand Up @@ -63,6 +59,10 @@ export class StringLiteralList extends Array {
const mut = this.mutable();
return mut.toSpliced.apply(mut, arguments);
}
with() {
const mut = this.mutable();
return mut.with.apply(mut, arguments);
}
}

export const ARRAY_IN_PLACE_MUTATION = Object.freeze({
Expand All @@ -78,7 +78,7 @@ export const ARRAY_IN_PLACE_MUTATION = Object.freeze({
reverse: 'reverse',
});
Object.values(ARRAY_IN_PLACE_MUTATION).forEach((el) => {
StringLiteralList.prototype[el] = () => {
SL.prototype[el] = () => {
throw new Error(`Array method ${el} is not supported by StringLiteralList`);
};
});
Loading

0 comments on commit 86d79ad

Please sign in to comment.