Skip to content
93 changes: 93 additions & 0 deletions library/src/actions/guard/guard.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expectTypeOf, test } from 'vitest';
import { pipe } from '../../methods/index.ts';
import { literal, number, string } from '../../schemas/index.ts';
import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts';
import type { GuardAction, GuardIssue } from './guard.ts';
import { guard } from './guard.ts';

describe('guard', () => {
type PixelString = `${number}px`;
const isPixelString = (input: string): input is PixelString =>
/^\d+px$/u.test(input);

describe('should return action object', () => {
test('with no message', () => {
expectTypeOf(guard(isPixelString)).toEqualTypeOf<
GuardAction<string, typeof isPixelString, undefined>
>();
});
test('with string message', () => {
expectTypeOf(guard(isPixelString, 'message')).toEqualTypeOf<
GuardAction<string, typeof isPixelString, 'message'>
>();
});

test('with function message', () => {
expectTypeOf(guard(isPixelString, () => 'message')).toEqualTypeOf<
GuardAction<string, typeof isPixelString, () => string>
>();
});
});

describe('should infer correct types', () => {
test('of input', () => {
expectTypeOf<
InferInput<GuardAction<string, typeof isPixelString, undefined>>
>().toEqualTypeOf<string>();
});

test('of output', () => {
expectTypeOf<
InferOutput<GuardAction<string, typeof isPixelString, undefined>>
>().toEqualTypeOf<PixelString>();
});

test('of issue', () => {
expectTypeOf<
InferIssue<GuardAction<string, typeof isPixelString, undefined>>
>().toEqualTypeOf<GuardIssue<string, typeof isPixelString>>();
});
});

test('should infer correct type in pipe', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const schema = pipe(
string(),
guard((input) => {
expectTypeOf(input).toEqualTypeOf<string>();
return isPixelString(input);
})
);
expectTypeOf<InferOutput<typeof schema>>().toEqualTypeOf<PixelString>();
});

test("should error if pipe input doesn't match", () => {
pipe(
number(),
// @ts-expect-error
guard(isPixelString)
);
});

test('should allow narrower input or wider output', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const narrowInput = pipe(
string(),
// guard allows wider input than current pipe
guard(
(input: unknown) => typeof input === 'string' && isPixelString(input)
)
);

expectTypeOf<
InferOutput<typeof narrowInput>
>().toEqualTypeOf<PixelString>();

// guarded type is wider than current pipe
// so we keep the narrower type
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const wideOutput = pipe(literal('123px'), guard(isPixelString));

expectTypeOf<InferOutput<typeof wideOutput>>().toEqualTypeOf<'123px'>();
});
});
90 changes: 90 additions & 0 deletions library/src/actions/guard/guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'vitest';
import type { FailureDataset } from '../../types/dataset.ts';
import type { GuardAction, GuardIssue } from './guard.ts';
import { guard } from './guard.ts';

describe('guard', () => {
type PixelString = `${number}px`;
const isPixelString = (input: string): input is PixelString =>
/^\d+px$/u.test(input);

const baseAction: Omit<
GuardAction<string, typeof isPixelString, undefined>,
'message'
> = {
kind: 'transformation',
type: 'guard',
reference: guard,
requirement: isPixelString,
async: false,
'~run': expect.any(Function),
};

describe('should return action object', () => {
test('with undefined message', () => {
const action: GuardAction<string, typeof isPixelString, undefined> = {
...baseAction,
message: undefined,
};
expect(guard(isPixelString)).toStrictEqual(action);
expect(guard(isPixelString, undefined)).toStrictEqual(action);
});

test('with string message', () => {
const action: GuardAction<string, typeof isPixelString, 'message'> = {
...baseAction,
message: 'message',
};
expect(guard(isPixelString, 'message')).toStrictEqual(action);
});

test('with function message', () => {
const message = () => 'message';
const action: GuardAction<string, typeof isPixelString, typeof message> =
{
...baseAction,
message,
};
expect(guard(isPixelString, message)).toStrictEqual(action);
});
});

test('should return dataset without issues', () => {
const action = guard(isPixelString);
const outputDataset = { typed: true, value: '123px' };
expect(action['~run']({ typed: true, value: '123px' }, {})).toStrictEqual(
outputDataset
);
});

test('should return dataset with issues', () => {
const action = guard(isPixelString, 'message');
const baseIssue: Omit<
GuardIssue<string, typeof isPixelString>,
'input' | 'received'
> = {
kind: 'transformation',
type: 'guard',
expected: null,
message: 'message',
requirement: isPixelString,
path: undefined,
issues: undefined,
lang: undefined,
abortEarly: undefined,
abortPipeEarly: undefined,
};

expect(action['~run']({ typed: true, value: '123' }, {})).toStrictEqual({
typed: false,
value: '123',
issues: [
{
...baseIssue,
input: '123',
received: '"123"',
},
],
} satisfies FailureDataset<GuardIssue<string, typeof isPixelString>>);
});
});
158 changes: 158 additions & 0 deletions library/src/actions/guard/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type {
BaseIssue,
BaseTransformation,
ErrorMessage,
} from '../../types/index.ts';
import { _addIssue } from '../../utils/index.ts';

type BaseGuard<TInput> = (
input: TInput
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => input is any;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InferGuarded<TGuard extends BaseGuard<any>> = TGuard extends (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
input: any
) => input is infer TOutput
? TOutput
: unknown;

/**
* Guard issue interface.
*/
export interface GuardIssue<TInput, TGuard extends BaseGuard<TInput>>
extends BaseIssue<TInput> {
/**
* The issue kind.
*/
readonly kind: 'transformation';
/**
* The validation type.
*/
readonly type: 'guard';
/**
* The validation requirement.
*/
readonly requirement: TGuard;
}

/**
* Guard action interface.
*/
export interface GuardAction<
TInput,
TGuard extends BaseGuard<TInput>,
TMessage extends ErrorMessage<GuardIssue<TInput, TGuard>> | undefined,
> extends BaseTransformation<
TInput,
// intersect in case guard is actually wider
TInput & InferGuarded<TGuard>,
GuardIssue<TInput, TGuard>
> {
/**
* The action type.
*/
readonly type: 'guard';
/**
* The action reference.
*/
readonly reference: typeof guard;
/**
* The guard function.
*/
readonly requirement: TGuard;
/**
* The error message.
*/
readonly message: TMessage;
}
/**
* Creates a guard validation action.
*
* @param requirement The guard function.
*
* @returns A guard action.
*/
// known input from pipe
export function guard<TInput, const TGuard extends BaseGuard<TInput>>(
requirement: TGuard
): GuardAction<TInput, TGuard, undefined>;

/**
* Creates a guard validation action.
*
* @param requirement The guard function.
*
* @returns A guard action.
*/
// unknown input, e.g. standalone
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function guard<const TGuard extends BaseGuard<any>>(
requirement: TGuard
): GuardAction<Parameters<TGuard>[0], TGuard, undefined>;

/**
* Creates a guard validation action.
*
* @param requirement The guard function.
* @param message The error message.
*
* @returns A guard action.
*/
// known input from pipe
export function guard<
TInput,
const TGuard extends BaseGuard<TInput>,
const TMessage extends ErrorMessage<GuardIssue<TInput, TGuard>> | undefined,
>(
requirement: TGuard,
message: TMessage
): GuardAction<TInput, TGuard, TMessage>;

/**
* Creates a guard validation action.
*
* @param requirement The guard function.
* @param message The error message.
*
* @returns A guard action.
*/
// unknown input, e.g. standalone
export function guard<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TGuard extends BaseGuard<any>,
const TMessage extends
| ErrorMessage<GuardIssue<Parameters<TGuard>[0], TGuard>>
| undefined,
>(
requirement: TGuard,
message: TMessage
): GuardAction<Parameters<TGuard>[0], TGuard, TMessage>;

// @__NO_SIDE_EFFECTS__
export function guard(
requirement: BaseGuard<unknown>,
message?: ErrorMessage<GuardIssue<unknown, BaseGuard<unknown>>>
): GuardAction<
unknown,
BaseGuard<unknown>,
ErrorMessage<GuardIssue<unknown, BaseGuard<unknown>>> | undefined
> {
return {
kind: 'transformation',
type: 'guard',
reference: guard,
async: false,
requirement,
message,
'~run'(dataset, config) {
if (dataset.typed && !this.requirement(dataset.value)) {
_addIssue(this, 'input', dataset, config);
// @ts-expect-error
dataset.typed = false;
}
return dataset;
},
};
}
1 change: 1 addition & 0 deletions library/src/actions/guard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './guard.ts';
1 change: 1 addition & 0 deletions library/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './finite/index.ts';
export * from './flavor/index.ts';
export * from './graphemes/index.ts';
export * from './gtValue/index.ts';
export * from './guard/index.ts';
export * from './hash/index.ts';
export * from './hexadecimal/index.ts';
export * from './hexColor/index.ts';
Expand Down
Loading
Loading