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
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,5 @@
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"dependencies": {
"ts-toolbelt": "^9.6.0"
}
"dependencies": {}
}
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 15 additions & 7 deletions src/lib/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { RuleFunctionsTable, Rule, FunctionsTable, Context, ResolvedConsequence, ValidationContext } from '../types';
import {
RuleFunctionsTable, Rule, FunctionsTable, Context, ResolvedConsequence,
ValidationContext, RuleDefinition
} from '../types';
import { evaluate, validate } from './evaluator';
import { objectKeys } from './helpers';
import { isRuleFunction } from './typeGuards';
Expand Down Expand Up @@ -45,22 +48,27 @@ async function run<ConsequencePayload, C extends Context,
}
}
} else {
if (!rule.condition) {
// Type assertion needed because TypeScript can't narrow RequireOnlyOne union types
const ruleDefinition = rule as RuleDefinition<ConsequencePayload, C, F, Ignore,
CustomEngineRuleFuncRunOptions>;
if (!ruleDefinition.condition) {
throw new Error(`Missing condition for rule`);
}
if (!rule.consequence) {
if (!ruleDefinition.consequence) {
throw new Error(`Missing consequence for rule`);
}
if (validation) {
await validate<C, F, Ignore, CustomEngineRuleFuncRunOptions>(rule.condition,
await validate<C, F, Ignore, CustomEngineRuleFuncRunOptions>(ruleDefinition.condition,
context as ValidationContext<C, Ignore>, functionsTable, runOptions);
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(context as C, rule.consequence);
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(
context as C, ruleDefinition.consequence);
} else {
const ruleApplies = await evaluate<C, F, Ignore, CustomEngineRuleFuncRunOptions>(
rule.condition, context as C, functionsTable, runOptions);
ruleDefinition.condition, context as C, functionsTable, runOptions);
if (ruleApplies) {
const consequence =
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(context as C, rule.consequence);
await evaluateEngineConsequence<ConsequencePayload, C, Ignore>(context as C,
ruleDefinition.consequence);
errors.push(consequence);
if (haltOnFirstMatch) {
return errors;
Expand Down
4 changes: 1 addition & 3 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Any } from 'ts-toolbelt';

export const getFromPath = <O extends object>(obj: O, path: string)
: { value: any, exists: boolean } => {
const keys = path.split('.');
let exists = keys.length > 0;
const value = keys.reduce((acc: any, key: Any.Key) => {
const value = keys.reduce((acc: any, key: PropertyKey) => {
const accessible = acc !== null && acc !== undefined;
exists = exists && accessible && key in acc;
return accessible ? acc[key] : undefined;
Expand Down
56 changes: 56 additions & 0 deletions src/test/benchmark.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import * as path from 'node:path';
import * as fs from 'node:fs';

/**
* Type-level performance regression guard.
*
* Compiles a deliberately heavy fixture (deep + wide context, an ExpressionHandler
* build, and a cross-context `or` assignment) with `tsc --extendedDiagnostics`, then
* asserts that:
* 1. It compiles with NO excessive-depth errors (TS2321 / TS2589). Reverting the
* `Path` materialization boundary to a ts-toolbelt-style implementation reproduces
* "Excessive stack depth comparing types 'Path<?, P>'" here.
* 2. The instantiation count stays under a budget. The current baseline is ~321k; a
* regression to the old `Path` encoding balloons it to ~2M.
*
* This runs the real `tsc` in a child process so it works identically on Linux/macOS/Windows.
*/
describe('type instantiation benchmark', () => {
// Baseline ~321k instantiations. Budget gives ~2x head-room for TS version drift while
// still catching the ~2M regression that a ts-toolbelt-style Path would introduce.
const INSTANTIATION_BUDGET = 700_000;

it('compiles the heavy fixture without excessive-depth errors and under budget', () => {
const repoRoot = path.resolve(__dirname, '..', '..');
const tscPath = path.resolve(repoRoot, 'node_modules', 'typescript', 'bin', 'tsc');
const projectPath = path.resolve(__dirname, 'benchmark', 'tsconfig.json');
expect(fs.existsSync(tscPath), `tsc not found at ${tscPath}`).toBe(true);

let output: string;
try {
output = execFileSync(
process.execPath,
[tscPath, '-p', projectPath, '--extendedDiagnostics'],
{ encoding: 'utf8', cwd: repoRoot },
);
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
const combined = `${e.stdout ?? ''}${e.stderr ?? ''}`;
throw new Error(`tsc failed to compile the benchmark fixture:\n${combined}`);
}

expect(output, 'benchmark fixture produced TypeScript errors').not.toMatch(/error TS/);

const match = output.match(/Instantiations:\s+(\d+)/);
expect(match, `could not parse Instantiations from tsc output:\n${output}`).toBeTruthy();

const instantiations = Number((match as RegExpMatchArray)[1]);
expect(
instantiations,
`type instantiations (${instantiations}) exceeded budget (${INSTANTIATION_BUDGET}); ` +
`a Path/RequireOnlyOne regression likely reintroduced excessive type expansion`,
).toBeLessThan(INSTANTIATION_BUDGET);
}, 60_000);
});
57 changes: 57 additions & 0 deletions src/test/benchmark/expression.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Type-level benchmark fixture. Compiled in isolation by benchmark.spec.ts with
// `tsc --extendedDiagnostics`; NOT part of the package build (excluded in tsconfig.json)
// and NOT executed by vitest. It exercises the heaviest type-machinery paths:
// - deep + wide context path extraction (Paths / StringPaths / PropertyCompareOps)
// - ExpressionHandler construction with a nested and/or/not expression
// - ValidationContext (deep NonNullable)
// - a cross-context `or` assignment (the comparison-heavy scenario that used to blow
// the relation stack — TS2321 "Excessive stack depth comparing Path<?, P>")
import { ExpressionHandler, ValidationContext } from '../../index';
import { Expression } from '../../types/evaluator';

interface Leaf {
a: string; b: number; c: boolean; d: string; e: number; f: boolean;
g: string | null; h: number | undefined; i: 'x' | 'y' | 'z'; j: string; k: number; l: boolean;
}
interface A4 extends Leaf { n: Leaf; o?: { p: number | null; q: string } }
interface A3 extends Leaf { n: A4 }
interface A2 extends Leaf { n: A3 }
interface A1 extends Leaf { n: A2 }
interface Ctx extends Leaf { n: A1 }

// A structurally-similar but distinct context so the `or` below forces a real
// structural comparison of two Expression types (not an identity short-circuit).
interface Leaf2 {
a: string; b: number; c: boolean; d: string; e: number; f: boolean;
g: string | null; h: number | undefined; i: 'x' | 'y' | 'z'; j: string; k: number; l: boolean;
}
interface B4 extends Leaf2 { n: Leaf2; o?: { p: number | null; q: string } }
interface B3 extends Leaf2 { n: B4 }
interface B2 extends Leaf2 { n: B3 }
interface B1 extends Leaf2 { n: B2 }
interface Ctx2 extends Leaf2 { n: B1 }

type F = {
fn1: (a: string, ctx: Ctx) => boolean;
fn2: (a: number, ctx: Ctx) => boolean;
};
type Custom = { dryRun: boolean };
declare const fns: F;

const handler = new ExpressionHandler<Ctx, F, never, Custom>({
and: [
{ 'n.n.n.a': { eq: 's' } },
{ or: [{ b: { gt: 5 } }, { fn1: 'x' }, { not: { 'n.n.c': { eq: true } } }] },
{ 'n.n.b': { neq: { ref: 'n.n.n.b' } } },
],
}, fns);

type E1 = Expression<Ctx, F, never, Custom>;
type E2 = Expression<Ctx2, {}, never, undefined>;
declare const e1a: E1;
declare const e1b: E1;
const crossOr: E2 = { or: [e1a, e1b] };

declare const vc: ValidationContext<Ctx>;

export { handler, crossOr, vc };
12 changes: 12 additions & 0 deletions src/test/benchmark/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"skipLibCheck": true,
"incremental": false
},
"include": [
"expression.fixture.ts"
],
"exclude": []
}
15 changes: 8 additions & 7 deletions src/test/types/expressionParts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable prefer-const */
import { ExpressionParts } from '../../types';
import { Any, Test } from 'ts-toolbelt';
import { Compute } from '../../types/utils';

interface ExpressionContext {
str: string;
Expand All @@ -23,7 +23,7 @@ type ExpressionFunction = {
boolArrFn: (a: boolean[], context: { str: string }) => boolean;
};

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

type Expected = {
'nested.value': {
Expand Down Expand Up @@ -100,7 +100,7 @@ declare let e: Expected;
r = e;
e = r;

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

type ExpectedExtended = {
Expand Down Expand Up @@ -183,7 +183,8 @@ type ExpectedExtended = {
},
};

Test.checks([
Test.check<Result, Expected, Test.Pass>(),
Test.check<ResultExtended, ExpectedExtended, Test.Pass>(),
]);
declare let rExt: ResultExtended;
declare let eExt: ExpectedExtended;

rExt = eExt;
eExt = rExt;
54 changes: 54 additions & 0 deletions src/test/types/validationContext.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ValidationContext } from '../../index';
import { expectAssignable, expectNotAssignable } from 'tsd';

// Regression test for NonNullable (deep) stripping through optional / nullable
// OBJECT properties. Before the fix, `SomeObject | undefined` failed the
// `extends object` guard and its nested nullables were left un-stripped, so
// `deep.inner.leaf` stayed `number | undefined` instead of `number`.
type DeepCtx = {
userId: string;
maybe?: {
inner: {
leaf: number | undefined;
};
};
nullableObj: {
val: string | null;
} | undefined;
};

type VC = ValidationContext<DeepCtx>;

// A fully-populated, fully non-null context is a valid ValidationContext.
expectAssignable<VC>({
userId: 'a',
maybe: { inner: { leaf: 5 } },
nullableObj: { val: 'x' },
});

// Deeply nested leaf behind an optional object must be non-null.
expectNotAssignable<VC>({
userId: 'a',
maybe: { inner: { leaf: undefined } },
nullableObj: { val: 'x' },
});

// Leaf behind a nullable object must be non-null.
expectNotAssignable<VC>({
userId: 'a',
maybe: { inner: { leaf: 5 } },
nullableObj: { val: null },
});

// The optional object itself becomes required in a ValidationContext.
expectNotAssignable<VC>({
userId: 'a',
nullableObj: { val: 'x' },
});

// The nullable object itself becomes required (cannot be undefined).
expectNotAssignable<VC>({
userId: 'a',
maybe: { inner: { leaf: 5 } },
nullableObj: undefined,
});
20 changes: 10 additions & 10 deletions src/types/evaluator.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { Object, String, Union } from 'ts-toolbelt';
import { Join, Path, RequireOnlyOne, Split } from './utils';
import { Paths } from './paths';
import { NonNullable } from './required';
import { NonNullable as NonNullableDeep } from './required';

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

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

export type Primitive = string | number | boolean;

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

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

type ExtractPrimitive<T> = Extract<Union.NonNullable<T>, Primitive>;
type ExtractPrimitive<T> = Extract<NonNullable<T>, Primitive>;

export type PropertyCompareOps<C extends Context, Ignore> = {
[K in StringPaths<C, Ignore>]:
ExtractPrimitive<Object.Path<C, String.Split<K, '.'>>> extends never ?
ExtractPrimitive<Path<C, Split<K, '.'>>> extends never ?
ExistsCompareOp :
(Extract<Object.Path<C, String.Split<K, '.'>>, Primitive | undefined> |
ExtendedCompareOp<C, Ignore, ExtractPrimitive<Object.Path<C, String.Split<K, '.'>>>>)
(Extract<Path<C, Split<K, '.'>>, Primitive | undefined> |
ExtendedCompareOp<C, Ignore, ExtractPrimitive<Path<C, Split<K, '.'>>>>)
};

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

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

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

export type Context = Record<string, any>;

export type ValidationContext<C extends Context, Ignore = never> = NonNullable<C, Ignore>;
export type ValidationContext<C extends Context, Ignore = never> = NonNullableDeep<C, Ignore>;

export interface EvaluationResult<C extends Context, F extends FunctionsTable<C, CustomEvaluatorFuncRunOptions>,
Ignore, CustomEvaluatorFuncRunOptions> {
Expand Down
Loading
Loading