Skip to content

Commit 87431d3

Browse files
authored
refactor: remove ts-toolbelt dependency (#53)
1 parent eb13c38 commit 87431d3

15 files changed

Lines changed: 351 additions & 58 deletions

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,5 @@
6363
"typescript": "^5.9.3",
6464
"vitest": "^4.0.18"
6565
},
66-
"dependencies": {
67-
"ts-toolbelt": "^9.6.0"
68-
}
66+
"dependencies": {}
6967
}

pnpm-lock.yaml

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/engine.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { RuleFunctionsTable, Rule, FunctionsTable, Context, ResolvedConsequence, ValidationContext } from '../types';
1+
import {
2+
RuleFunctionsTable, Rule, FunctionsTable, Context, ResolvedConsequence,
3+
ValidationContext, RuleDefinition
4+
} from '../types';
25
import { evaluate, validate } from './evaluator';
36
import { objectKeys } from './helpers';
47
import { isRuleFunction } from './typeGuards';
@@ -45,22 +48,27 @@ async function run<ConsequencePayload, C extends Context,
4548
}
4649
}
4750
} else {
48-
if (!rule.condition) {
51+
// Type assertion needed because TypeScript can't narrow RequireOnlyOne union types
52+
const ruleDefinition = rule as RuleDefinition<ConsequencePayload, C, F, Ignore,
53+
CustomEngineRuleFuncRunOptions>;
54+
if (!ruleDefinition.condition) {
4955
throw new Error(`Missing condition for rule`);
5056
}
51-
if (!rule.consequence) {
57+
if (!ruleDefinition.consequence) {
5258
throw new Error(`Missing consequence for rule`);
5359
}
5460
if (validation) {
55-
await validate<C, F, Ignore, CustomEngineRuleFuncRunOptions>(rule.condition,
61+
await validate<C, F, Ignore, CustomEngineRuleFuncRunOptions>(ruleDefinition.condition,
5662
context as ValidationContext<C, Ignore>, functionsTable, runOptions);
57-
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(context as C, rule.consequence);
63+
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(
64+
context as C, ruleDefinition.consequence);
5865
} else {
5966
const ruleApplies = await evaluate<C, F, Ignore, CustomEngineRuleFuncRunOptions>(
60-
rule.condition, context as C, functionsTable, runOptions);
67+
ruleDefinition.condition, context as C, functionsTable, runOptions);
6168
if (ruleApplies) {
6269
const consequence =
63-
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(context as C, rule.consequence);
70+
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(context as C,
71+
ruleDefinition.consequence);
6472
errors.push(consequence);
6573
if (haltOnFirstMatch) {
6674
return errors;

src/lib/helpers.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { Any } from 'ts-toolbelt';
2-
31
export const getFromPath = <O extends object>(obj: O, path: string)
42
: { value: any, exists: boolean } => {
53
const keys = path.split('.');
64
let exists = keys.length > 0;
7-
const value = keys.reduce((acc: any, key: Any.Key) => {
5+
const value = keys.reduce((acc: any, key: PropertyKey) => {
86
const accessible = acc !== null && acc !== undefined;
97
exists = exists && accessible && key in acc;
108
return accessible ? acc[key] : undefined;

src/test/benchmark.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { execFileSync } from 'node:child_process';
3+
import * as path from 'node:path';
4+
import * as fs from 'node:fs';
5+
6+
/**
7+
* Type-level performance regression guard.
8+
*
9+
* Compiles a deliberately heavy fixture (deep + wide context, an ExpressionHandler
10+
* build, and a cross-context `or` assignment) with `tsc --extendedDiagnostics`, then
11+
* asserts that:
12+
* 1. It compiles with NO excessive-depth errors (TS2321 / TS2589). Reverting the
13+
* `Path` materialization boundary to a ts-toolbelt-style implementation reproduces
14+
* "Excessive stack depth comparing types 'Path<?, P>'" here.
15+
* 2. The instantiation count stays under a budget. The current baseline is ~321k; a
16+
* regression to the old `Path` encoding balloons it to ~2M.
17+
*
18+
* This runs the real `tsc` in a child process so it works identically on Linux/macOS/Windows.
19+
*/
20+
describe('type instantiation benchmark', () => {
21+
// Baseline ~321k instantiations. Budget gives ~2x head-room for TS version drift while
22+
// still catching the ~2M regression that a ts-toolbelt-style Path would introduce.
23+
const INSTANTIATION_BUDGET = 700_000;
24+
25+
it('compiles the heavy fixture without excessive-depth errors and under budget', () => {
26+
const repoRoot = path.resolve(__dirname, '..', '..');
27+
const tscPath = path.resolve(repoRoot, 'node_modules', 'typescript', 'bin', 'tsc');
28+
const projectPath = path.resolve(__dirname, 'benchmark', 'tsconfig.json');
29+
expect(fs.existsSync(tscPath), `tsc not found at ${tscPath}`).toBe(true);
30+
31+
let output: string;
32+
try {
33+
output = execFileSync(
34+
process.execPath,
35+
[tscPath, '-p', projectPath, '--extendedDiagnostics'],
36+
{ encoding: 'utf8', cwd: repoRoot },
37+
);
38+
} catch (err) {
39+
const e = err as { stdout?: string; stderr?: string };
40+
const combined = `${e.stdout ?? ''}${e.stderr ?? ''}`;
41+
throw new Error(`tsc failed to compile the benchmark fixture:\n${combined}`);
42+
}
43+
44+
expect(output, 'benchmark fixture produced TypeScript errors').not.toMatch(/error TS/);
45+
46+
const match = output.match(/Instantiations:\s+(\d+)/);
47+
expect(match, `could not parse Instantiations from tsc output:\n${output}`).toBeTruthy();
48+
49+
const instantiations = Number((match as RegExpMatchArray)[1]);
50+
expect(
51+
instantiations,
52+
`type instantiations (${instantiations}) exceeded budget (${INSTANTIATION_BUDGET}); ` +
53+
`a Path/RequireOnlyOne regression likely reintroduced excessive type expansion`,
54+
).toBeLessThan(INSTANTIATION_BUDGET);
55+
}, 60_000);
56+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Type-level benchmark fixture. Compiled in isolation by benchmark.spec.ts with
2+
// `tsc --extendedDiagnostics`; NOT part of the package build (excluded in tsconfig.json)
3+
// and NOT executed by vitest. It exercises the heaviest type-machinery paths:
4+
// - deep + wide context path extraction (Paths / StringPaths / PropertyCompareOps)
5+
// - ExpressionHandler construction with a nested and/or/not expression
6+
// - ValidationContext (deep NonNullable)
7+
// - a cross-context `or` assignment (the comparison-heavy scenario that used to blow
8+
// the relation stack — TS2321 "Excessive stack depth comparing Path<?, P>")
9+
import { ExpressionHandler, ValidationContext } from '../../index';
10+
import { Expression } from '../../types/evaluator';
11+
12+
interface Leaf {
13+
a: string; b: number; c: boolean; d: string; e: number; f: boolean;
14+
g: string | null; h: number | undefined; i: 'x' | 'y' | 'z'; j: string; k: number; l: boolean;
15+
}
16+
interface A4 extends Leaf { n: Leaf; o?: { p: number | null; q: string } }
17+
interface A3 extends Leaf { n: A4 }
18+
interface A2 extends Leaf { n: A3 }
19+
interface A1 extends Leaf { n: A2 }
20+
interface Ctx extends Leaf { n: A1 }
21+
22+
// A structurally-similar but distinct context so the `or` below forces a real
23+
// structural comparison of two Expression types (not an identity short-circuit).
24+
interface Leaf2 {
25+
a: string; b: number; c: boolean; d: string; e: number; f: boolean;
26+
g: string | null; h: number | undefined; i: 'x' | 'y' | 'z'; j: string; k: number; l: boolean;
27+
}
28+
interface B4 extends Leaf2 { n: Leaf2; o?: { p: number | null; q: string } }
29+
interface B3 extends Leaf2 { n: B4 }
30+
interface B2 extends Leaf2 { n: B3 }
31+
interface B1 extends Leaf2 { n: B2 }
32+
interface Ctx2 extends Leaf2 { n: B1 }
33+
34+
type F = {
35+
fn1: (a: string, ctx: Ctx) => boolean;
36+
fn2: (a: number, ctx: Ctx) => boolean;
37+
};
38+
type Custom = { dryRun: boolean };
39+
declare const fns: F;
40+
41+
const handler = new ExpressionHandler<Ctx, F, never, Custom>({
42+
and: [
43+
{ 'n.n.n.a': { eq: 's' } },
44+
{ or: [{ b: { gt: 5 } }, { fn1: 'x' }, { not: { 'n.n.c': { eq: true } } }] },
45+
{ 'n.n.b': { neq: { ref: 'n.n.n.b' } } },
46+
],
47+
}, fns);
48+
49+
type E1 = Expression<Ctx, F, never, Custom>;
50+
type E2 = Expression<Ctx2, {}, never, undefined>;
51+
declare const e1a: E1;
52+
declare const e1b: E1;
53+
const crossOr: E2 = { or: [e1a, e1b] };
54+
55+
declare const vc: ValidationContext<Ctx>;
56+
57+
export { handler, crossOr, vc };

src/test/benchmark/tsconfig.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "../../../tsconfig.json",
3+
"compilerOptions": {
4+
"noEmit": true,
5+
"skipLibCheck": true,
6+
"incremental": false
7+
},
8+
"include": [
9+
"expression.fixture.ts"
10+
],
11+
"exclude": []
12+
}

src/test/types/expressionParts.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable prefer-const */
22
import { ExpressionParts } from '../../types';
3-
import { Any, Test } from 'ts-toolbelt';
3+
import { Compute } from '../../types/utils';
44

55
interface ExpressionContext {
66
str: string;
@@ -23,7 +23,7 @@ type ExpressionFunction = {
2323
boolArrFn: (a: boolean[], context: { str: string }) => boolean;
2424
};
2525

26-
type Result = Any.Compute<ExpressionParts<ExpressionContext, ExpressionFunction, {}, never, {dryRun: boolean}>>;
26+
type Result = Compute<ExpressionParts<ExpressionContext, ExpressionFunction, {}, never, {dryRun: boolean}>>;
2727

2828
type Expected = {
2929
'nested.value': {
@@ -100,7 +100,7 @@ declare let e: Expected;
100100
r = e;
101101
e = r;
102102

103-
type ResultExtended = Any.Compute<ExpressionParts<ExpressionContext, ExpressionFunction, { description: string }, never,
103+
type ResultExtended = Compute<ExpressionParts<ExpressionContext, ExpressionFunction, { description: string }, never,
104104
{dryRun: boolean}>>;
105105

106106
type ExpectedExtended = {
@@ -183,7 +183,8 @@ type ExpectedExtended = {
183183
},
184184
};
185185

186-
Test.checks([
187-
Test.check<Result, Expected, Test.Pass>(),
188-
Test.check<ResultExtended, ExpectedExtended, Test.Pass>(),
189-
]);
186+
declare let rExt: ResultExtended;
187+
declare let eExt: ExpectedExtended;
188+
189+
rExt = eExt;
190+
eExt = rExt;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { ValidationContext } from '../../index';
2+
import { expectAssignable, expectNotAssignable } from 'tsd';
3+
4+
// Regression test for NonNullable (deep) stripping through optional / nullable
5+
// OBJECT properties. Before the fix, `SomeObject | undefined` failed the
6+
// `extends object` guard and its nested nullables were left un-stripped, so
7+
// `deep.inner.leaf` stayed `number | undefined` instead of `number`.
8+
type DeepCtx = {
9+
userId: string;
10+
maybe?: {
11+
inner: {
12+
leaf: number | undefined;
13+
};
14+
};
15+
nullableObj: {
16+
val: string | null;
17+
} | undefined;
18+
};
19+
20+
type VC = ValidationContext<DeepCtx>;
21+
22+
// A fully-populated, fully non-null context is a valid ValidationContext.
23+
expectAssignable<VC>({
24+
userId: 'a',
25+
maybe: { inner: { leaf: 5 } },
26+
nullableObj: { val: 'x' },
27+
});
28+
29+
// Deeply nested leaf behind an optional object must be non-null.
30+
expectNotAssignable<VC>({
31+
userId: 'a',
32+
maybe: { inner: { leaf: undefined } },
33+
nullableObj: { val: 'x' },
34+
});
35+
36+
// Leaf behind a nullable object must be non-null.
37+
expectNotAssignable<VC>({
38+
userId: 'a',
39+
maybe: { inner: { leaf: 5 } },
40+
nullableObj: { val: null },
41+
});
42+
43+
// The optional object itself becomes required in a ValidationContext.
44+
expectNotAssignable<VC>({
45+
userId: 'a',
46+
nullableObj: { val: 'x' },
47+
});
48+
49+
// The nullable object itself becomes required (cannot be undefined).
50+
expectNotAssignable<VC>({
51+
userId: 'a',
52+
maybe: { inner: { leaf: 5 } },
53+
nullableObj: undefined,
54+
});

src/types/evaluator.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
import { Object, String, Union } from 'ts-toolbelt';
1+
import { Join, Path, RequireOnlyOne, Split } from './utils';
22
import { Paths } from './paths';
3-
import { NonNullable } from './required';
3+
import { NonNullable as NonNullableDeep } from './required';
44

55
export type FuncCompareOp<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
66
K extends keyof F, CustomEvaluatorFuncRunOptions> =
77
Awaited<Parameters<F[K]>[0]>;
88

99
export type StringPaths<O extends object, Ignore> = any extends O ?
10-
never : (any extends Ignore ? never : String.Join<Paths<O, [], Ignore | any[], string>, '.'>);
10+
never : (any extends Ignore ? never : Join<Paths<O, [], Ignore | any[], string>, '.'>);
1111

1212
export type Primitive = string | number | boolean;
1313

1414
export type PropertyPathsOfType<C extends Context, Ignore, V extends Primitive> = {
1515
[K in StringPaths<C, Ignore>]:
16-
Union.NonNullable<Extract<Object.Path<C, String.Split<K, '.'>>, V>> extends V ? 1 : 0;
16+
NonNullable<Extract<Path<C, Split<K, '.'>>, V>> extends V ? 1 : 0;
1717
};
1818

1919
export type ExtractPropertyPathsOfType<T extends Record<string, 1 | 0>> = {
@@ -106,14 +106,14 @@ export type ExtendedCompareOp<C extends Context, Ignore, V extends Primitive> =
106106
NinCompareOp<C, Ignore, V> | NumberCompareOps<C, Ignore, V> | StringCompareOps<C, Ignore, V> |
107107
ExistsCompareOp;
108108

109-
type ExtractPrimitive<T> = Extract<Union.NonNullable<T>, Primitive>;
109+
type ExtractPrimitive<T> = Extract<NonNullable<T>, Primitive>;
110110

111111
export type PropertyCompareOps<C extends Context, Ignore> = {
112112
[K in StringPaths<C, Ignore>]:
113-
ExtractPrimitive<Object.Path<C, String.Split<K, '.'>>> extends never ?
113+
ExtractPrimitive<Path<C, Split<K, '.'>>> extends never ?
114114
ExistsCompareOp :
115-
(Extract<Object.Path<C, String.Split<K, '.'>>, Primitive | undefined> |
116-
ExtendedCompareOp<C, Ignore, ExtractPrimitive<Object.Path<C, String.Split<K, '.'>>>>)
115+
(Extract<Path<C, Split<K, '.'>>, Primitive | undefined> |
116+
ExtendedCompareOp<C, Ignore, ExtractPrimitive<Path<C, Split<K, '.'>>>>)
117117
};
118118

119119
export interface AndCompareOp<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
@@ -131,7 +131,7 @@ export interface NotCompareOp<C extends Context, F extends FunctionsTable<C, Cus
131131
not: Expression<C, F, Ignore, CustomEvaluatorFuncRunOptions>;
132132
}
133133

134-
export type RequireOnlyOne<T extends object> = Object.Either<T, keyof T>;
134+
export { RequireOnlyOne };
135135

136136
export type FullExpression<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
137137
Ignore, CustomEvaluatorFuncRunOptions> =
@@ -157,7 +157,7 @@ export type FunctionsTable<T, CustomEvaluatorFuncRunOptions> = Record<string, Fu
157157

158158
export type Context = Record<string, any>;
159159

160-
export type ValidationContext<C extends Context, Ignore = never> = NonNullable<C, Ignore>;
160+
export type ValidationContext<C extends Context, Ignore = never> = NonNullableDeep<C, Ignore>;
161161

162162
export interface EvaluationResult<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
163163
Ignore, CustomEvaluatorFuncRunOptions> {

0 commit comments

Comments
 (0)