Skip to content
20 changes: 10 additions & 10 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ Each has its own folder: `name.ts`, `name.test.ts`, `name.test-d.ts`, `index.ts`

## Detailed Guides

See `/prompts/index.md` for task-specific instructions:

| Task | Guide |
| ----------------------------- | --------------------------------------- |
| Navigate repo, find files | `prompts/repository-structure.md` |
| Write JSDoc / inline comments | `prompts/document-source-code.md` |
| Review PRs and source changes | `prompts/review-source-code-changes.md` |
| Add new API page to website | `prompts/add-new-api-to-website.md` |
| Update existing API docs | `prompts/update-api-on-website.md` |
| Add guide/tutorial to website | `prompts/add-new-guide-to-website.md` |
**Before performing any task listed below, OPEN and READ the corresponding guide file.**

| Task | Guide (read before starting) |
| ----------------------------- | --------------------------------------------------------------------------------- |
| Navigate repo, find files | [prompts/repository-structure.md](../prompts/repository-structure.md) |
| Write JSDoc / inline comments | [prompts/document-source-code.md](../prompts/document-source-code.md) |
| Review PRs and source changes | [prompts/review-source-code-changes.md](../prompts/review-source-code-changes.md) |
| Add new API page to website | [prompts/add-new-api-to-website.md](../prompts/add-new-api-to-website.md) |
| Update existing API docs | [prompts/update-api-on-website.md](../prompts/update-api-on-website.md) |
| Add guide/tutorial to website | [prompts/add-new-guide-to-website.md](../prompts/add-new-guide-to-website.md) |

**Source code is the single source of truth.** All documentation must match `/library/src/`.
4 changes: 4 additions & 0 deletions library/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to the library will be documented in this file.

## vX.X.X (Month DD, YYYY)

- Add `guard` transformation action to narrow types using type predicates (pull request #1204)

## v1.2.0 (November 24, 2025)

- Add `toBigint`, `toBoolean`, `toDate`, `toNumber` and `toString` transformation actions (pull request #1212)
Expand Down
94 changes: 94 additions & 0 deletions library/src/actions/guard/guard.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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>>);
});
});
Loading
Loading