Skip to content

Commit 768d97e

Browse files
committed
feat: 🎸 add function type to schema
1 parent f7e3252 commit 768d97e

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ const schema = s.object({
426426
- `s.number()` - Number validation
427427
- `s.boolean()` - Boolean validation
428428
- `s.date()` - Date validation
429+
- `s.function()` - Function validation
429430
- `s.object(def)` - Object validation
430431
- `s.array(def)` - Array validation
431432
- `s.literal(value)` - Literal value validation
@@ -590,6 +591,27 @@ const dateSchemaFunc = s.date({
590591
});
591592
```
592593

594+
#### `s.function(options?)`
595+
596+
Creates a function schema that validates the value is callable.
597+
598+
- **`message`**: Can be either a constant string or a function `(value) => string`.
599+
600+
```typescript
601+
const callbackSchema = s.function();
602+
callbackSchema.parse(() => {}); // () => {}
603+
callbackSchema.parse(async () => {}); // async () => {}
604+
callbackSchema.parse('hello'); // throws
605+
606+
const callbackSchemaMsg = s.function({
607+
message: 'Expected a callback function.',
608+
});
609+
610+
const callbackSchemaFunc = s.function({
611+
message: (value) => `Custom error: "${value}" is not a function.`,
612+
});
613+
```
614+
593615
#### `s.object(definition, options?)`
594616

595617
Creates an object schema with the given definition. You can optionally pass `options` to customize error messages.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import assert from 'node:assert';
2+
import { describe, it } from 'node:test';
3+
import { s } from '../index.ts';
4+
5+
describe('s.function()', () => {
6+
// ─── valid inputs ──────────────────────────────────────────────────────────
7+
it('passes an arrow function', () => {
8+
const fn = () => {};
9+
assert.strictEqual(s.function().parse(fn), fn);
10+
});
11+
12+
it('passes a named function', () => {
13+
function handler() {}
14+
assert.strictEqual(s.function().parse(handler), handler);
15+
});
16+
17+
it('passes an async function', () => {
18+
const fn = async () => {};
19+
assert.strictEqual(s.function().parse(fn), fn);
20+
});
21+
22+
it('passes a function with arguments', () => {
23+
const fn = (a, b) => a + b;
24+
assert.strictEqual(s.function().parse(fn), fn);
25+
});
26+
27+
it('passes a class constructor (callable)', () => {
28+
class MyClass {}
29+
assert.strictEqual(s.function().parse(MyClass), MyClass);
30+
});
31+
32+
// ─── invalid inputs ────────────────────────────────────────────────────────
33+
it('throws for a string', () =>
34+
assert.throws(() => s.function().parse('hello')));
35+
36+
it('throws for a number', () => assert.throws(() => s.function().parse(42)));
37+
38+
it('throws for null', () => assert.throws(() => s.function().parse(null)));
39+
40+
it('throws for undefined', () =>
41+
assert.throws(() => s.function().parse(undefined)));
42+
43+
it('throws for a plain object', () =>
44+
assert.throws(() => s.function().parse({ call: () => {} })));
45+
46+
it('throws for an array', () => assert.throws(() => s.function().parse([])));
47+
48+
it('throws for a boolean', () =>
49+
assert.throws(() => s.function().parse(true)));
50+
51+
// ─── error message ─────────────────────────────────────────────────────────
52+
it('default error message contains the invalid value', () => {
53+
const result = s.function().safeParse('nope');
54+
assert.equal(result.success, false);
55+
assert.ok(result.error.message.includes('nope'));
56+
});
57+
58+
it('supports custom message as string', () => {
59+
const schema = s.function({ message: 'Expected a callback' });
60+
const result = schema.safeParse(42);
61+
assert.equal(result.success, false);
62+
assert.equal(result.error.message, 'Expected a callback');
63+
});
64+
65+
it('supports custom message as function', () => {
66+
const schema = s.function({ message: (v) => `"${v}" is not a function` });
67+
const result = schema.safeParse(42);
68+
assert.equal(result.success, false);
69+
assert.equal(result.error.message, '"42" is not a function');
70+
});
71+
72+
// ─── modifiers ─────────────────────────────────────────────────────────────
73+
it('.optional() allows undefined', () => {
74+
const schema = s.function().optional();
75+
assert.equal(schema.parse(undefined), undefined);
76+
const fn = () => {};
77+
assert.strictEqual(schema.parse(fn), fn);
78+
});
79+
80+
it('.nullable() allows null', () => {
81+
const schema = s.function().nullable();
82+
assert.equal(schema.parse(null), null);
83+
const fn = () => {};
84+
assert.strictEqual(schema.parse(fn), fn);
85+
});
86+
87+
it('.refine() chains correctly', () => {
88+
const schema = s.function().refine((fn) => fn.length === 2, {
89+
message: 'Must accept exactly 2 arguments',
90+
});
91+
assert.ok(schema.parse((a, b) => a + b));
92+
assert.throws(() => schema.parse(() => {}));
93+
});
94+
95+
// ─── inside s.object() ─────────────────────────────────────────────────────
96+
it('works as a field inside s.object()', () => {
97+
const schema = s.object({
98+
name: s.string(),
99+
onClick: s.function(),
100+
});
101+
const fn = () => {};
102+
const result = schema.parse({ name: 'btn', onClick: fn });
103+
assert.equal(result.name, 'btn');
104+
assert.strictEqual(result.onClick, fn);
105+
});
106+
107+
it('fails inside s.object() when field is not a function', () => {
108+
const schema = s.object({
109+
name: s.string(),
110+
onClick: s.function(),
111+
});
112+
assert.throws(() => schema.parse({ name: 'btn', onClick: 'not-a-fn' }));
113+
});
114+
115+
it('safeParse returns success:false for invalid function field', () => {
116+
const schema = s.object({ handler: s.function() });
117+
const result = schema.safeParse({ handler: null });
118+
assert.equal(result.success, false);
119+
});
120+
});

src/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ export interface NumberSchemaInterface
8080
export interface BooleanSchemaInterface
8181
extends SchemaInterface<boolean, boolean> {}
8282
export interface DateSchemaInterface extends SchemaInterface<Date, Date> {}
83+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
84+
export interface FunctionSchemaInterface
85+
extends SchemaInterface<Function, Function> {}
8386
export interface ArraySchemaInterface<T extends SchemaType>
8487
extends SchemaInterface<
8588
Array<ReturnType<T['parse']>>,
@@ -121,6 +124,8 @@ export type SchemaType =
121124
| SchemaInterface<Date, Date | undefined>
122125
| SchemaInterface<Date, Date | null>
123126
| SchemaInterface<Date, Date | undefined | null>
127+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
128+
| FunctionSchemaInterface
124129
| EnumSchemaInterface<string>
125130
| UnionSchemaInterface<Array<SchemaInterface<unknown, unknown>>>
126131

@@ -206,6 +211,10 @@ const booleanValidation = (value: unknown): value is boolean =>
206211
const dateValidation = (value: unknown): value is Date =>
207212
value instanceof Date && !Number.isNaN(value.getTime());
208213

214+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
215+
const functionValidation = (value: unknown): value is Function =>
216+
typeof value === 'function';
217+
209218
const arrayValidation = (value: unknown): value is unknown[] =>
210219
Array.isArray(value);
211220

@@ -385,6 +394,27 @@ export const s = {
385394
type: 'date',
386395
}) as DateSchemaInterface;
387396
},
397+
/**
398+
* Creates a function schema that validates the value is callable.
399+
*
400+
* @param options - Optional configuration (name, message)
401+
* @returns Function schema interface
402+
*
403+
* @example
404+
* ```typescript
405+
* const callbackSchema = s.function();
406+
* callbackSchema.parse(() => {}); // () => {}
407+
* callbackSchema.parse('hello'); // throws
408+
* ```
409+
*/
410+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
411+
function(options?: SchemaInterfaceOptions): FunctionSchemaInterface {
412+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
413+
return createSchemaInterface<Function, Function>(functionValidation, {
414+
...options,
415+
type: 'function',
416+
}) as FunctionSchemaInterface;
417+
},
388418
/**
389419
* Creates an enum schema with predefined values.
390420
*

0 commit comments

Comments
 (0)