Skip to content

Commit

Permalink
feat: support standard schema validators (#120)
Browse files Browse the repository at this point in the history
Co-authored-by: Kent C. Dodds <[email protected]>
  • Loading branch information
Xiphe and kentcdodds authored Feb 7, 2025
1 parent 2070f4e commit ad36a59
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 25 deletions.
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,18 +177,19 @@ interface CachifiedOptions<Value> {
/**
* Validator that checks every cached and fresh value to ensure type safety
*
* Can be a zod schema or a custom validator function
* Can be a standard schema validator or a custom validator function
* @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec
*
* Value considered ok when:
* - zod schema.parseAsync succeeds
* - schema succeeds
* - validator returns
* - true
* - migrate(newValue)
* - undefined
* - null
*
* Value considered bad when:
* - zod schema.parseAsync throws
* - schema throws
* - validator:
* - returns false
* - returns reason as string
Expand All @@ -200,7 +201,10 @@ interface CachifiedOptions<Value> {
*
* Default: `undefined` - no validation
*/
checkValue?: CheckValue<Value> | Schema<Value, unknown>;
checkValue?:
| CheckValue<Value>
| StandardSchemaV1<unknown, Value>
| Schema<Value, unknown>;
/**
* Set true to not even try reading the currently cached value
*
Expand Down Expand Up @@ -406,9 +410,9 @@ console.log(await getUserById(1));

> ℹ️ `checkValue` is also invoked with the return value of `getFreshValue`
### Type-safety with [zod](https://github.com/colinhacks/zod)
### Type-safety with [schema libraries](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec)

We can also use zod schemas to ensure correct types
We can also use zod, valibot or other libraries implementing the standard schema spec to ensure correct types

<!-- type-safety-zod -->

Expand Down
19 changes: 10 additions & 9 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@
"jest": "29.7.0",
"ts-jest": "29.2.5",
"typescript": "5.7.3",
"zod": "3.24.1"
"zod-legacy": "npm:[email protected]"
}
}
70 changes: 70 additions & 0 deletions src/StandardSchemaV1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/** The Standard Schema interface. */
export interface StandardSchemaV1<Input = unknown, Output = Input> {
/** The Standard Schema properties. */
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
}

export declare namespace StandardSchemaV1 {
/** The Standard Schema properties interface. */
export interface Props<Input = unknown, Output = Input> {
/** The version number of the standard. */
readonly version: 1;
/** The vendor name of the schema library. */
readonly vendor: string;
/** Validates unknown input values. */
readonly validate: (
value: unknown,
) => Result<Output> | Promise<Result<Output>>;
/** Inferred types associated with the schema. */
readonly types?: Types<Input, Output> | undefined;
}

/** The result interface of the validate function. */
export type Result<Output> = SuccessResult<Output> | FailureResult;

/** The result interface if validation succeeds. */
export interface SuccessResult<Output> {
/** The typed output value. */
readonly value: Output;
/** The non-existent issues. */
readonly issues?: undefined;
}

/** The result interface if validation fails. */
export interface FailureResult {
/** The issues of failed validation. */
readonly issues: ReadonlyArray<Issue>;
}

/** The issue interface of the failure output. */
export interface Issue {
/** The error message of the issue. */
readonly message: string;
/** The path of the issue, if any. */
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
}

/** The path segment interface of the issue. */
export interface PathSegment {
/** The key representing a path segment. */
readonly key: PropertyKey;
}

/** The Standard Schema types interface. */
export interface Types<Input = unknown, Output = Input> {
/** The input type of the schema. */
readonly input: Input;
/** The output type of the schema. */
readonly output: Output;
}

/** Infers the input type of a Standard Schema. */
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
Schema['~standard']['types']
>['input'];

/** Infers the output type of a Standard Schema. */
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
Schema['~standard']['types']
>['output'];
}
53 changes: 52 additions & 1 deletion src/cachified.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import z from 'zod';
/*
TODO(next-major): remove zod-legacy in favor of zod@>3.24
and update tests to only use standard schema
*/
import z from 'zod-legacy';
import {
cachified,
CachifiedOptions,
Expand All @@ -12,6 +16,7 @@ import {
} from './index';
import { Deferred } from './createBatch';
import { delay, report } from './testHelpers';
import { StandardSchemaV1 } from './StandardSchemaV1';
import { configure } from './configure';

jest.mock('./index', () => {
Expand Down Expand Up @@ -389,6 +394,52 @@ describe('cachified', () => {
expect(await getValue()).toBe(123);
});

it('supports Standard Schema as validators', async () => {
// Implement the schema interface
const checkValue: StandardSchemaV1<string, number> = {
'~standard': {
version: 1,
vendor: 'cachified-test',
validate(value) {
return typeof value === 'string'
? { value: parseInt(value, 10) }
: { issues: [{ message: '🙅' }] };
},
},
};

const cache = new Map<string, CacheEntry>();

const value = await cachified({
cache,
key: 'test',
checkValue,
getFreshValue() {
return '123';
},
});

expect(value).toBe(123);

const invalidValue = cachified({
cache,
key: 'test-2',
checkValue,
getFreshValue() {
return { invalid: 'value' };
},
});

await expect(invalidValue).rejects.toThrowErrorMatchingInlineSnapshot(
`"check failed for fresh value of test-2"`,
);
await ignoreNode14(() =>
expect(
invalidValue.catch((err) => err.cause[0].message),
).resolves.toMatchInlineSnapshot(`"🙅"`),
);
});

it('supports migrating cached values', async () => {
const cache = new Map<string, CacheEntry>();
const reporter = createReporter();
Expand Down
54 changes: 46 additions & 8 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CreateReporter, Reporter } from './reporter';
import { StandardSchemaV1 } from './StandardSchemaV1';

export interface CacheMetadata {
createdTime: number;
Expand Down Expand Up @@ -51,10 +52,17 @@ export type ValueCheckResultInvalid = false | string;
export type ValueCheckResult<Value> =
| ValueCheckResultOk<Value>
| ValueCheckResultInvalid;

export type CheckValue<Value> = (
value: unknown,
migrate: (value: Value, updateCache?: boolean) => MigratedValue<Value>,
) => ValueCheckResult<Value> | Promise<ValueCheckResult<Value>>;

/**
* @deprecated use a library supporting Standard Schema
* @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec
* @todo remove in next major version
*/
export interface Schema<Value, InputValue> {
_input: InputValue;
parseAsync(value: unknown): Promise<Value>;
Expand Down Expand Up @@ -122,30 +130,34 @@ export interface CachifiedOptions<Value> {
/**
* Validator that checks every cached and fresh value to ensure type safety
*
* Can be a zod schema or a custom validator function
* Can be a standard schema validator or a custom validator function
* @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec
*
* Value considered ok when:
* - zod schema.parseAsync succeeds
* - schema succeeds
* - validator returns
* - true
* - migrate(newValue)
* - undefined
* - null
*
* Value considered bad when:
* - zod schema.parseAsync throws
* - schema throws
* - validator:
* - returns false
* - returns reason as string
* - throws
*
* A validator function receives two arguments:
* 1. the value
* 2. a migrate callback, see https://github.com/Xiphe/cachified#migrating-values
* 2. a migrate callback, see https://github.com/epicweb-dev/cachified#migrating-values
*
* Default: `undefined` - no validation
*/
checkValue?: CheckValue<Value> | Schema<Value, unknown>;
checkValue?:
| CheckValue<Value>
| StandardSchemaV1<unknown, Value>
| Schema<Value, unknown>;
/**
* Set true to not even try reading the currently cached value
*
Expand Down Expand Up @@ -195,7 +207,7 @@ export type CachifiedOptionsWithSchema<Value, InternalValue> = Omit<
CachifiedOptions<Value>,
'checkValue' | 'getFreshValue'
> & {
checkValue: Schema<Value, InternalValue>;
checkValue: StandardSchemaV1<unknown, Value> | Schema<Value, InternalValue>;
getFreshValue: GetFreshValue<InternalValue>;
};

Expand All @@ -210,6 +222,33 @@ export interface Context<Value>
metadata: CacheMetadata;
}

function validateWithSchema<Value>(
checkValue: StandardSchemaV1<unknown, Value> | Schema<Value, unknown>,
): CheckValue<Value> {
return async (value, migrate) => {
let validatedValue;

/* Standard Schema validation
https://github.com/standard-schema/standard-schema?tab=readme-ov-file#how-do-i-accept-standard-schemas-in-my-library */
if ('~standard' in checkValue) {
let result = checkValue['~standard'].validate(value);
if (result instanceof Promise) result = await result;

if (result.issues) {
throw result.issues;
}

validatedValue = result.value;
} else {
/* Legacy Schema validation for zod only
TODO: remove in next major version */
validatedValue = await checkValue.parseAsync(value);
}

return migrate(validatedValue, false);
};
}

export function createContext<Value>(
{ fallbackToCache, checkValue, ...options }: CachifiedOptions<Value>,
reporter?: CreateReporter<Value>,
Expand All @@ -220,8 +259,7 @@ export function createContext<Value>(
typeof checkValue === 'function'
? checkValue
: typeof checkValue === 'object'
? (value, migrate) =>
checkValue.parseAsync(value).then((v) => migrate(v, false))
? validateWithSchema(checkValue)
: () => true;

const contextWithoutReport = {
Expand Down

0 comments on commit ad36a59

Please sign in to comment.