Skip to content
Open
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
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