Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,28 @@
{
"name": "es5-full",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "37.75 kb",
"limit": "39 kb",
"brotli": false,
"running": false
},
{
"name": "es6-full",
"path": "lib/dist/es6/mod/ts-utils.js",
"limit": "36.5 kb",
"limit": "37.5 kb",
"brotli": false,
"running": false
},
{
"name": "es5-full-brotli",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "13 kb",
"limit": "13.25 kb",
"brotli": true,
"running": false
},
{
"name": "es6-full-brotli",
"path": "lib/dist/es6/mod/ts-utils.js",
"limit": "12.75 kb",
"limit": "13.25 kb",
"brotli": true,
"running": false
},
Expand All @@ -37,7 +37,7 @@
{
"name": "es6-zip",
"path": "lib/dist/es6/mod/ts-utils.js",
"limit": "14 Kb",
"limit": "14.5 Kb",
"gzip": true,
"running": false
},
Expand Down Expand Up @@ -107,23 +107,23 @@
{
"name": "es5-scheduleMicrotask",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "1.75 kb",
"limit": "2 kb",
"import": "{ scheduleMicrotask }",
"gzip": true,
"running": false
},
{
"name": "es5-scheduleNextTick",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "2 kb",
"limit": "2.25 kb",
"import": "{ scheduleNextTick }",
"gzip": true,
"running": false
},
{
"name": "es5-scheduleNextTickAndMicrotask",
"path": "lib/dist/es5/mod/ts-utils.js",
"limit": "2 kb",
"limit": "2.25 kb",
"import": "{ scheduleNextTick, scheduleMicrotask }",
"gzip": true,
"running": false
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@
- `polyObjKeys` no longer invokes property getters (so it no longer throws for an object with a throwing getter), matching the native `Object.keys`
- `polyObjEntries` and `polyObjValues` still invoke each getter once and propagate any exception it throws, matching the native `Object.entries` and `Object.values`

- [#634](https://github.com/nevware21/ts-utils/issues/634) Case helpers (`strCamelCase`, `strKebabCase`, `strSnakeCase`, `strLetterCase`, `strCapitalizeWords`): inconsistent word boundaries and non-ASCII letters dropped
- **Output change:** callers that depend on the previous output for the inputs below will see different results
- `strKebabCase` and `strSnakeCase` now also split words at the end of an uppercase run before a capitalized word and where a number is followed by an uppercase letter, eg. `"XMLHttpRequest"` -> `"xml-http-request"` (was `"xmlhttp-request"`), `"ABCDef"` -> `"abc-def"` (was `"abcdef"`), `"fooBar2Baz"` -> `"foo-bar2-baz"` (was `"foo-bar2baz"`) and `"hELLo"` -> `"h-el-lo"` (was `"h-ello"`). This also makes more camelCase values reversible, eg. `"fooBar2Baz"` -> `"foo-bar2-baz"` -> `"fooBar2Baz"` (was `"fooBar2baz"`)
- Non-ASCII letters, numbers and combining marks are now part of words rather than separators (only whitespace, punctuation and symbols are separators), eg. `"café crème"` -> `"caféCrème"` / `"café-crème"` (was `"cafCrMe"` / `"caf-cr-me"`) and `strCapitalizeWords("café crème")` -> `"Café Crème"` (was `"Café CrèMe"`). The first letter of a word is case converted as a complete character, including supplementary plane (surrogate pair) letters, eg. `strLetterCase("𐐨")` -> `"𐐀"`
- Uppercase letters (word boundaries) are detected for the Latin, Latin-1, Greek (including the accented capitals, eg. `"ώραΈναρξης"` -> `"ώρα-έναρξης"`) and Cyrillic ranges in both their precomposed and decomposed (NFC / NFD) forms, other cased letters (eg. `"Ł"`, `"Č"`) are retained and case converted but don't start a new word (`"łódźŁódź"` -> `"łódźłódź"`), while a decomposed letter whose base letter is within those ranges does (`"C"` followed by a combining caron), so normalize values that may contain both forms first. The same fixed ranges are used on every runtime (no Unicode property escapes, which require ES2018), so the output does not depend on the runtime
- `strLetterCase` and `strCapitalizeWords` now also capitalize a word that follows repeated `_` characters, eg. `"__init__"` -> `"__Init__"` (was `"__init__"`), consistent with `"_init"` -> `"_Init"`
- Unchanged, so the conversions remain reversible: `strCamelCase` only changes the first letter of each word and retains the case of the other letters (`"XMLHttpRequest"` -> `"xMLHttpRequest"`, `strCamelCase("XMLHttpRequest", true)` -> `"XMLHttpRequest"`), `strKebabCase` and `strSnakeCase` remove leading and trailing whitespace but retain any other leading or trailing separators as a single separator (`"_hello_darkness"` <-> `"-hello-darkness"`), and `strCapitalizeWords` only splits words at separators (`"fooBarBaz"` -> `"Foobarbaz"`, now documented)

### Performance Improvements

- Case helpers (`strCamelCase`, `strKebabCase`, `strSnakeCase`, `strLetterCase`, `strCapitalizeWords`) now detect and convert every word in a single `strReplace()` regular expression pass, with no `strTrim()` or whole result `strLower()` / `strUpper()` passes, intermediate word arrays or per-character iteration, and call the native case conversion on each word directly (via minifiable method name constants). Compared to v0.16.0 for short identifiers / a 100KB string: `strCamelCase` ~2.4x / ~1.8x, `strKebabCase` ~1.3x / ~1.4x, `strSnakeCase` ~1.6x / ~1.3x, `strLetterCase` ~1.8x / ~2.2x and `strCapitalizeWords` ~2.1x / ~2.1x faster
- `strUpper()`, `strLower()`, `strSubstring()`, `strIndexOf()`, `strReplace()`, `strPadStart()`, `strTrim()`, `arrIndexOf()`, `arrMap()` and the other helpers that wrap a native prototype function (`_unwrapFunctionWithPoly()` / `_unwrapFunctionNoInstWithPoly()`) now forward their arguments with the intrinsic `Function.prototype.call` (`FuncCall.apply(theFunc, arguments)`) rather than slicing them into a new array on every call, making each call ~23-51% faster. As the intrinsic is used they also no longer depend on the native function's own `call` / `apply` properties
- `fnCall()` is now an alias of the intrinsic `Function.prototype.call` bound to itself (`FuncCall.bind(FuncCall)`, so `fnCall(fn, thisArg, ...args)` is `fn.call(thisArg, ...args)`) rather than a wrapper function that sliced the arguments into a new array and called `fn.apply()`. This is ~1.8x (polymorphic call sites) to ~30x (monomorphic) faster per call in V8 micro-benchmarks and smaller, which also benefits the helpers that call it per item (eg. `arrDropWhile()`, `arrFlatMap()` and the iterator helpers). It also no longer depends on the function's own (overridable) `call` / `apply` properties, and as it's now a bound function `fnCall.name` is `"bound call"` and `fnCall.length` is `1`
- `fnApply()` is now an alias of the intrinsic `Function.prototype.call` bound to the intrinsic `Function.prototype.apply` (`FuncCall.bind(FuncApply)`, so `fnApply(fn, thisArg, argArray)` is `fn.apply(thisArg, argArray)`) rather than a wrapper function calling `fn.apply()`, which is smaller with the same or slightly better performance (within ~10% in V8 micro-benchmarks), used by the timer, cache, lazy, `arrAppend()`, `createFnDeferredProxy()` and custom error helpers. It also no longer depends on the function's own (overridable) `call` / `apply` properties, and `fnApply.name` is `"bound call"` and `fnApply.length` is `1`
- `fnBind()` now forwards the arguments object to the intrinsic `Function.prototype.bind` (`FuncCall.apply(FuncBind, arguments)`, which is `fn.bind(thisArg, ...args)`) rather than slicing the arguments into a new array and calling `fn.bind.apply()`, ~60-69% faster per call in V8 micro-benchmarks. It remains a function so the existing overloads are unchanged, and it no longer depends on the function's own (overridable) `bind` / `call` / `apply` properties

# v0.16.0 July 18th, 2026

## Changelog
Expand Down
81 changes: 21 additions & 60 deletions lib/src/funcs/funcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Licensed under the MIT license.
*/

import { ArrSlice, CALL } from "../internal/constants";
import { FuncApply, FuncBind, FuncCall } from "../internal/constants";
import { BoundFunction } from "./types";

/**
Expand All @@ -23,6 +23,11 @@ import { BoundFunction } from "./types";
* For example, you could use a NodeList, or a custom object like `{ 'length': 2, '0': 'eat', '1': 'bananas' }`.
* You can also use `arguments`.
*
* `fnApply` is the intrinsic `Function.prototype.call` bound to the intrinsic `Function.prototype.apply`, so
* `fnApply(fn, thisArg, argArray)` is `fn.apply(thisArg, argArray)` without any wrapper function, and it doesn't
* depend on the `call` or `apply` properties of `fn` (which may have been overridden).
*
* @function
* @since 0.9.8
* @group Function
*
Expand Down Expand Up @@ -67,13 +72,11 @@ import { BoundFunction } from "./types";
* fnApply(module1.log, module2, [ "friend" ]); // my friend : 42
* ```
*/
export function fnApply<F extends (...args: any) => any, T>(fn: F, thisArg: T, argArray?: ArrayLike<any>): ReturnType<F> {
return fn.apply(thisArg, argArray);
}
export const fnApply: <F extends (...args: any) => any, T>(fn: F, thisArg: T, argArray?: ArrayLike<any>) => ReturnType<F> = (/*#__PURE__*/FuncCall.bind(FuncApply));

/**
* The `fnCall` function calls the function with the given `thisArg` as the `this` value and with
* al of the `_args` provided as it's `arguments`.
* all of the `args` provided as it's `arguments`.
*
* Note: This is almost identical to `fnApply`, except that the function arguments are passed to `fnCall`
* individually as a list, while with `fnApply` that are combined into a single array argument.
Expand All @@ -83,13 +86,18 @@ export function fnApply<F extends (...args: any) => any, T>(fn: F, thisArg: T, a
* existing function, without first attaching the function to the object as a property. This allows you
* to use methods of one object as generic utility functions.
*
* `fnCall` is the intrinsic `Function.prototype.call` bound to itself, so `fnCall(fn, thisArg, ...args)` is
* `fn.call(thisArg, ...args)` without any wrapper function or copying of the arguments, and it doesn't depend
* on the `call` or `apply` properties of `fn` (which may have been overridden).
*
* @function
* @since 0.9.8
* @group Function
*
* @param fn - The function to be called
* @param thisArg - The value of `this` provided for the call to `fn`. If the function is not in strict mode,
* `null` and `undefined` will be replaced with the global object, and primitive values will be converted to objects.
* @param _args - The zero or more arguments to be passed to the `fn` function.
* @param args - The zero or more arguments to be passed to the `fn` function.
* @returns The result of calling the function with the specified `this` value and arguments.
* @example
* ```ts
Expand Down Expand Up @@ -126,59 +134,7 @@ export function fnApply<F extends (...args: any) => any, T>(fn: F, thisArg: T, a
* fnCall(module1.log, module2, "friend"); // my friend : 42
* ```
*/
export function fnCall<F extends (...args: any) => any, T>(fn: F, thisArg: T, ...args: Parameters<F>): ReturnType<F>;

/**
* The `fnCall` function calls the function with the given `thisArg` as the `this` value and with
* al of the `_args` provided as it's `arguments.
*
* > This is almost identical to `fnApply`, except that the function arguments are passed to `fnCall`
* individually as a list, while with `fnApply` that are combined into a single array argument.
*
* Normally, when calling a function, the value of `this` inside the function is the object that the
* function was accessed on. With `fnCall()`, you can pass an arbitrary value as the `this` when calling an
* existing function, without first attaching the function to the object as a property. This allows you
* to use methods of one object as generic utility functions.
*
* @since 0.9.8
* @group Function
*
* @param fn - The function to be called
* @param thisArg - The value of `this` provided for the call to `fn`. If the function is not in strict mode,
* `null` and `undefined` will be replaced with the global object, and primitive values will be converted to objects.
* @param args - The zero or more arguments to be passed to the `fn` function.
* @returns The result of calling the function with the specified `this` value and arguments.
* @example
* ```ts
* const module1 = {
* prefix: "Hello",
* x: 21,
* getX() {
* return this.x;
* },
* log(value: string) {
* return this.prefix + " " + value + " : " + this.x
* }
* };
*
* // The 'this' parameter of 'getX' is bound to 'module'.
* module1.getX(); // 21
* module1.log("Darkness"); // Hello Darkness : 21
*
* // Create a new function 'boundGetX' with the 'this' parameter bound to 'module'.
* let module2 = {
* prefix: "my",
* x: 42
* };
*
* // Call the function of module1 with module2 as it's this
* fnCall(module1.getX, module2); // 42
* fnCall(module1.log, module2, "friend"); // my friend : 42
* ```
*/
export function fnCall<F extends (...args: any) => any, T>(fn: F, thisArg: T): ReturnType<F> {
return fn.apply(thisArg, ArrSlice[CALL](arguments, 2));
}
export const fnCall: <F extends (...args: any) => any, T>(fn: F, thisArg: T, ...args: Parameters<F>) => ReturnType<F> = (/*#__PURE__*/FuncCall.bind(FuncCall));

/**
* Creates a new function that when called will set the value of `thisArg` as the `this` keyword
Expand Down Expand Up @@ -283,5 +239,10 @@ export function fnBind<F extends Function, T>(fn: F, thisArg: T, ...argArray: an

/*#__NO_SIDE_EFFECTS__*/
export function fnBind<F extends (...args: any[]) => any, T>(fn: F, thisArg: T): any {
return fn.bind.apply(fn, ArrSlice[CALL](arguments, 1) as any);
// The arguments are [fn, thisArg, ...args], so this invokes the intrinsic Function.prototype.bind with fn
// as its `this`, which is fn.bind(thisArg, ...args). Passing the arguments object directly to apply()
// avoids slicing them into a new array, and using the intrinsic means this doesn't depend on the fn's own
// (overridable) bind / call / apply properties. This remains a function (rather than an alias like fnCall)
// to retain the overloads, and no other code runs before arguments is read.
return FuncCall.apply(FuncBind, arguments);
}
26 changes: 26 additions & 0 deletions lib/src/internal/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export const LENGTH = "length";
export const NAME = "name";
export const CALL = "call";
export const TO_STRING = "toString";
export const TO_UPPER_CASE = "toUpperCase";
export const TO_LOWER_CASE = "toLowerCase";
export const SUBSTRING = "substring";

export const GET_OWN_PROPERTY_DESCRIPTOR = "getOwnPropertyDescriptor";
export const GET_OWN_PROPERTY_NAMES = "getOwnPropertyNames";
Expand Down Expand Up @@ -92,6 +95,29 @@ export const FuncCls = (/*#__PURE__*/_pureAssign(Function)) as FunctionConstruct
*/
export const FuncProto = (/*#__PURE__*/_pureRef<typeof Function.prototype>(FuncCls, PROTOTYPE)) as Function;

/**
* @ignore
* The intrinsic `Function.prototype.call`, `FuncCall.apply(fn, [thisArg, ...args])` is the same as
* `fn.call(thisArg, ...args)` without depending on the `call` / `apply` properties of `fn` (which may be
* overridden) and when passed an `arguments` object it forwards them without allocating a new array.
*/
export const FuncCall = (/*#__PURE__*/_pureRef<typeof Function.prototype.call>(FuncProto, CALL)) as Function;

/**
* @ignore
* The intrinsic `Function.prototype.apply`, `FuncCall.bind(FuncApply)` is a function where
* `(fn, thisArg, argArray)` is the same as `fn.apply(thisArg, argArray)` without depending on the
* `call` / `apply` properties of `fn` (which may be overridden).
*/
export const FuncApply = (/*#__PURE__*/_pureRef<typeof Function.prototype.apply>(FuncProto, "apply")) as Function;

/**
* @ignore
* The intrinsic `Function.prototype.bind`, `FuncCall.apply(FuncBind, [fn, thisArg, ...args])` is the same
* as `fn.bind(thisArg, ...args)` without depending on the `bind` / `call` / `apply` properties of `fn`.
*/
export const FuncBind = (/*#__PURE__*/_pureRef<typeof Function.prototype.bind>(FuncProto, "bind")) as Function;

/**
* @ignore
*/
Expand Down
24 changes: 17 additions & 7 deletions lib/src/internal/unwrapFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { dumpObj } from "../helpers/diagnostics";
import { throwTypeError } from "../helpers/throw";
import { asString } from "../string/as_string";
import { ArrSlice, CALL, NULL_VALUE } from "./constants";
import { FuncCall, LENGTH, NULL_VALUE } from "./constants";

/**
* @function
Expand Down Expand Up @@ -49,13 +49,20 @@ export function _unwrapFunctionWithPoly<T, P extends (...args: any) => any>(func
let clsFn = clsProto ? clsProto[funcName] : NULL_VALUE;

return function(thisArg: any): ReturnType<P> {
// Capture this invocations arguments first (supports all ES5 runtimes). This function MUST also work
// in non-strict mode where the arguments object is mapped to the named parameters, so thisArg must
// never be assigned as that would also change theArgs[0].
// theArgs is also referenced by the error message, so minifiers don't collapse it back into `arguments`.
let theArgs = arguments;
let theFunc = (thisArg ? thisArg[funcName] : NULL_VALUE) || clsFn;
if (theFunc || polyFunc) {
let theArgs = arguments;
return ((theFunc || polyFunc) as Function).apply(thisArg, theFunc ? ArrSlice[CALL](theArgs, 1) : theArgs);
// theFunc.call(thisArg, ...rest) or polyFunc(thisArg, ...rest), passing the arguments object directly
// to apply() (rather than slicing it) avoids an array allocation on every call. The intrinsic
// Function.prototype.call is used so this doesn't depend on theFunc's own (overridable) call property
return ((theFunc ? FuncCall : polyFunc) as Function).apply(theFunc || thisArg, theArgs);
}

throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg));
throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg) + " with " + theArgs[LENGTH] + " args");
};
}

Expand All @@ -76,13 +83,16 @@ export function _unwrapFunctionNoInstWithPoly<T, P extends (...args: any) => any
let clsFn = clsProto ? clsProto[funcName] : NULL_VALUE;

return function(thisArg: any): ReturnType<P> {
// See _unwrapFunctionWithPoly(), capture the arguments first (also referenced by the error message so
// minifiers keep it) and never assign thisArg (non-strict mode)
let theArgs = arguments;
let theFunc = clsFn;
if (theFunc || polyFunc) {
let theArgs = arguments;
return ((theFunc || polyFunc) as Function).apply(thisArg, theFunc ? ArrSlice[CALL](theArgs, 1) : theArgs);
// Forwards the arguments without slicing them, using the intrinsic Function.prototype.call
return ((theFunc ? FuncCall : polyFunc) as Function).apply(theFunc || thisArg, theArgs);
}

throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg));
throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg) + " with " + theArgs[LENGTH] + " args");
};
}

Expand Down
Loading
Loading