Last active
March 10, 2025 23:57
-
-
Save qpwo/eb680743d9445f98642010b4afd18567 to your computer and use it in GitHub Desktop.
my typescript type validator (not zod!)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* my-validator.test.ts | |
* | |
* Test suite for my-validator.ts library | |
*/ | |
import { objSchema, arrSchema, unionSchema, validate, Infer } from './my-validator' | |
// Test 1: Basic object schema validation | |
function testBasicObject() { | |
const UserSchema = objSchema({ id: 0, name: '' } as const) | |
const user = validate(UserSchema, { id: 1, name: 'John' }) | |
if (user.id !== 1 || user.name !== 'John') { | |
throw new Error('Basic object validation failed') | |
} | |
} | |
// Test 2: Basic array schema validation | |
function testBasicArray() { | |
const NumbersSchema = arrSchema([0] as const) | |
const numbers = validate(NumbersSchema, [1, 2, 3]) | |
if (numbers.length !== 3 || numbers[0] !== 1) { | |
throw new Error('Basic array validation failed') | |
} | |
} | |
// Test 3: Basic union schema validation | |
function testBasicUnion() { | |
const ActionSchema = unionSchema('type', { | |
add: { type: 'add', value: 0 }, | |
remove: { type: 'remove', id: '' }, | |
} as const) | |
const action = validate(ActionSchema, { type: 'add', value: 5 }) | |
if (action.type !== 'add' || action.value !== 5) { | |
throw new Error('Basic union validation failed') | |
} | |
} | |
// Test 4: Nested object schema | |
function testNestedObject() { | |
const UserSchema = objSchema({ | |
id: 0, | |
profile: { age: 0, email: '' }, | |
} as const) | |
const user = validate(UserSchema, { | |
id: 1, | |
profile: { age: 30, email: '[email protected]' }, | |
}) | |
if (user.profile.age !== 30) { | |
throw new Error('Nested object validation failed') | |
} | |
} | |
// Test 5: Object with array property | |
function testObjectWithArray() { | |
const UserSchema = objSchema({ | |
id: 0, | |
tags: [''] as const, | |
} as const) | |
const user = validate(UserSchema, { | |
id: 1, | |
tags: ['developer', 'typescript'], | |
}) | |
if (user.tags.length !== 2 || user.tags[0] !== 'developer') { | |
throw new Error('Object with array validation failed') | |
} | |
} | |
// Test 6: Array of objects | |
function testArrayOfObjects() { | |
const UsersSchema = arrSchema([{ id: 0, name: '' }] as const) | |
const users = validate(UsersSchema, [ | |
{ id: 1, name: 'John' }, | |
{ id: 2, name: 'Jane' }, | |
]) | |
if (users[1].name !== 'Jane') { | |
throw new Error('Array of objects validation failed') | |
} | |
} | |
// Test 7: Union schema with multiple variants | |
function testMultipleUnionVariants() { | |
const ShapeSchema = unionSchema('shape', { | |
circle: { shape: 'circle', radius: 0 }, | |
rectangle: { shape: 'rectangle', width: 0, height: 0 }, | |
} as const) | |
const circle = validate(ShapeSchema, { shape: 'circle', radius: 5 }) | |
const rectangle = validate(ShapeSchema, { shape: 'rectangle', width: 10, height: 20 }) | |
// Type assertion to help TypeScript | |
if ((circle as any).radius !== 5 || (rectangle as any).width !== 10) { | |
throw new Error('Union variants validation failed') | |
} | |
} | |
// Test 8: Object with wrong input type | |
function testObjectWrongType() { | |
const UserSchema = objSchema({ id: 0, name: '' } as const) | |
try { | |
validate(UserSchema, 'not an object') | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 9: Array with wrong input type | |
function testArrayWrongType() { | |
const NumbersSchema = arrSchema([0] as const) | |
try { | |
validate(NumbersSchema, { not: 'an array' }) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 10: Union missing discriminator | |
function testUnionMissingDiscriminator() { | |
const ActionSchema = unionSchema('type', { | |
add: { type: 'add', value: 0 }, | |
} as const) | |
try { | |
validate(ActionSchema, { value: 5 }) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 11: Union with invalid discriminator value | |
function testUnionInvalidDiscriminator() { | |
const ActionSchema = unionSchema('type', { | |
add: { type: 'add', value: 0 }, | |
} as const) | |
try { | |
validate(ActionSchema, { type: 'unknown', value: 5 }) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 12: Object with missing property | |
function testObjectMissingProperty() { | |
const UserSchema = objSchema({ id: 0, name: '' } as const) | |
try { | |
validate(UserSchema, { id: 1 }) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 13: Object with property of wrong type | |
function testObjectPropertyWrongType() { | |
const UserSchema = objSchema({ id: 0, name: '' } as const) | |
try { | |
validate(UserSchema, { id: 'wrong type', name: 'John' }) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 14: Array with item of wrong type | |
function testArrayItemWrongType() { | |
const NumbersSchema = arrSchema([0] as const) | |
try { | |
validate(NumbersSchema, [1, 'not a number', 3]) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 15: Type inference for object schema | |
function testObjectTypeInference() { | |
const UserSchema = objSchema({ id: 0, name: '', active: true } as const) | |
type User = Infer<typeof UserSchema> | |
const user: User = { id: 1, name: 'John', active: false } | |
// @ts-expect-error - id should be number | |
const invalidUser1: User = { id: '1', name: 'John', active: true } | |
// @ts-expect-error - missing required property | |
const invalidUser2: User = { id: 1, name: 'John' } | |
} | |
// Test 16: Type inference for array schema | |
function testArrayTypeInference() { | |
const NumbersSchema = arrSchema([0] as const) | |
type Numbers = Infer<typeof NumbersSchema> | |
const numbers: Numbers = [1, 2, 3] | |
// @ts-expect-error - should be number[] | |
const invalidNumbers: Numbers = ['1', '2', '3'] | |
} | |
// Test 17: Type inference for union schema | |
function testUnionTypeInference() { | |
const ActionSchema = unionSchema('type', { | |
add: { type: 'add', value: 0 }, | |
remove: { type: 'remove', id: 0 }, | |
} as const) | |
type Action = Infer<typeof ActionSchema> | |
// Type assertions to match the expected types | |
const addAction: Action = { type: 'add', value: 5 } as Action | |
const removeAction: Action = { type: 'remove', id: 1 } as Action | |
// @ts-expect-error - invalid discriminator value | |
const invalidAction1: Action = { type: 'invalid', value: 5 } | |
// @ts-expect-error - wrong property type | |
const invalidAction2: Action = { type: 'add', value: '5' } | |
} | |
// Test 18: Complex nested schema | |
function testComplexNestedSchema() { | |
const AppSchema = objSchema({ | |
name: '', | |
version: 0, | |
settings: { | |
theme: '', | |
notifications: true, | |
}, | |
users: [{ id: 0, name: '', roles: [''] }], | |
} as const) | |
const app = validate(AppSchema, { | |
name: 'My App', | |
version: 1, | |
settings: { | |
theme: 'dark', | |
notifications: false, | |
}, | |
users: [ | |
{ id: 1, name: 'John', roles: ['admin', 'user'] }, | |
{ id: 2, name: 'Jane', roles: ['user'] }, | |
], | |
}) | |
if (app.users[0].roles[0] !== 'admin' || app.settings.theme !== 'dark') { | |
throw new Error('Complex schema validation failed') | |
} | |
} | |
// Test 19: Invalid union schema definition | |
function testInvalidUnionSchemaDefinition() { | |
try { | |
unionSchema('type', { | |
add: { type: 'add', value: 0 }, | |
remove: { type: 'delete', id: 0 }, // Mismatch between key and discriminator value | |
} as const) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Test 20: Boolean validation | |
function testBooleanValidation() { | |
const SettingsSchema = objSchema({ | |
darkMode: true, | |
notifications: false, | |
} as const) | |
const settings = validate(SettingsSchema, { | |
darkMode: false, | |
notifications: true, | |
}) | |
if (settings.darkMode !== false || settings.notifications !== true) { | |
throw new Error('Boolean validation failed') | |
} | |
try { | |
validate(SettingsSchema, { | |
darkMode: 0, // Wrong type | |
notifications: false, | |
}) | |
throw new Error("Should have failed but didn't") | |
} catch (error) { | |
// Expected error | |
} | |
} | |
// Run all tests | |
function runAllTests() { | |
testBasicObject() | |
testBasicArray() | |
testBasicUnion() | |
testNestedObject() | |
testObjectWithArray() | |
testArrayOfObjects() | |
testMultipleUnionVariants() | |
testObjectWrongType() | |
testArrayWrongType() | |
testUnionMissingDiscriminator() | |
testUnionInvalidDiscriminator() | |
testObjectMissingProperty() | |
testObjectPropertyWrongType() | |
testArrayItemWrongType() | |
testObjectTypeInference() | |
testArrayTypeInference() | |
testUnionTypeInference() | |
testComplexNestedSchema() | |
testInvalidUnionSchemaDefinition() | |
testBooleanValidation() | |
// @ts-expect-error | |
console.log('All tests passed!') | |
} | |
// Execute tests | |
runAllTests() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** Create minimal simple typescript-friendly validation library. Here's what we have: | |
- PrimitiveArray is boolean[] | string[] | number[] | |
- PrimitiveObject is like Record<string, boolean | string | number> | |
- MyArray is an array of [primitive or PrimitiveArray or PrimitiveObject] | |
- MyObject is an object of [primitives or PrimitiveArray or PrimitiveObject] | |
- MyUnion is a collection of PrimitiveObject discriminated by the provided key | |
That's it. 1 or 2 layers. No more and no fewer. | |
Use literals for types. Basically you give an instance of the class. The schema() function is implemented as identity, but it has needed types. | |
```ts | |
import { objSchema, arrSchema, unionSchema, Infer, validate } from 'my-validator.ts' | |
export const User = objSchema({ id: 0, name: '', favoriteColors: [''] } as const) | |
export type User = Infer<User> // {id: number, name: string, favoriteColors: string[]} | |
export const Things = arrSchema([{ cool: true }] as const) // always singleton | |
export type Things = Infer<Things> // {cool: true}[] | |
export const Action = unionSchema('action', { | |
foo: { action: 'foo', x: 0 }, | |
bar: { action: 'bar', y: 0, z: '' }, | |
} as const) // does run-time validation when unionSchema() is called to check keys match discriminator | |
export type Action = Infer<Action> // {action: 'foo', x: number} | { action: 'bar', y: number, z: string } | |
const user = validate(User, JSON.parse(input)) // has type User | |
const things = validate(Things, JSON.parse(input)) // has type Things | |
// etc | |
// validate should fail with like "obj.id should be int but is undefined" or "arr[4] was string instead of number" if there is mismatch | |
``` | |
Implementation guidelines: | |
- The fewer lines the better. | |
- Flat is better than nested. | |
- Keep it as simple and flat and direct as possible. | |
- Make it perfectly correct. | |
- Make it extremely efficient. | |
- A simpler implementation is almost always better. | |
*/ | |
/** Type utility to generalize schema types */ | |
type Generalize<T> = T extends boolean | |
? boolean | |
: T extends string | |
? string | |
: T extends number | |
? number | |
: T extends ReadonlyArray<infer U> | |
? readonly Generalize<U>[] | |
: T extends object | |
? { readonly [K in keyof T]: Generalize<T[K]> } | |
: never | |
/** Schema creators */ | |
export const objSchema = <S extends Record<string, any>>(schema: S) => ({ type: 'object', schema }) as const | |
export const arrSchema = <A extends any[]>(schema: A) => ({ type: 'array', schema }) as const | |
export const unionSchema = <D extends string, V extends Record<string, Record<string, any>>>(discriminator: D, variants: V) => { | |
for (const key in variants) { | |
if (variants[key][discriminator] !== key) { | |
throw new Error(`Variant ${key} has incorrect discriminator`) | |
} | |
} | |
return { type: 'union', discriminator, variants } as const | |
} | |
/** Infers the TypeScript type from a schema */ | |
export type Infer<T> = T extends { type: 'object'; schema: infer S } | |
? Generalize<S> | |
: T extends { type: 'array'; schema: (infer E)[] } | |
? Generalize<E>[] | |
: T extends { type: 'union'; discriminator: infer D; variants: infer V } | |
? { | |
-readonly [K in keyof V]: { | |
-readonly [P in keyof V[K]]: P extends D ? V[K][P] : Generalize<V[K][P]> | |
} | |
}[keyof V] | |
: never | |
/** Validates input against schema with runtime checks */ | |
function validateValue(schema: any, input: any, path: string): void { | |
if (Array.isArray(schema)) { | |
if (!Array.isArray(input)) throw new Error(`${path} should be array, got ${typeof input}`) | |
input.forEach((item, i) => validateValue(schema[0], item, `${path}[${i}]`)) | |
} else if (typeof schema === 'object' && schema !== null) { | |
if (typeof input !== 'object' || input === null) throw new Error(`${path} should be object, got ${typeof input}`) | |
const sKeys = Object.keys(schema) | |
const iKeys = Object.keys(input) | |
const missing = sKeys.filter(k => !(k in input)) | |
if (missing.length) throw new Error(`${path} missing keys: ${missing.join(', ')}`) | |
const extra = iKeys.filter(k => !(k in schema)) | |
if (extra.length) throw new Error(`${path} has extra keys: ${extra.join(', ')}`) | |
sKeys.forEach(k => validateValue(schema[k], input[k], `${path}.${k}`)) | |
} else { | |
if (typeof input !== typeof schema) throw new Error(`${path} should be ${typeof schema}, got ${typeof input}`) | |
} | |
} | |
export function validate<T>(schema: T, input: any): Infer<T> { | |
// @ts-expect-error | |
if (schema.type === 'object') validateValue(schema.schema, input, 'obj') | |
// @ts-expect-error | |
else if (schema.type === 'array') validateValue(schema.schema, input, 'arr') | |
// @ts-expect-error | |
else if (schema.type === 'union') { | |
if (typeof input !== 'object' || input === null) throw new Error(`obj should be object, got ${typeof input}`) | |
// @ts-expect-error | |
const key = input[schema.discriminator] | |
// @ts-expect-error | |
if (typeof key !== 'string' || !(key in schema.variants)) throw new Error(`obj.${schema.discriminator} is invalid`) | |
// @ts-expect-error | |
validateValue(schema.variants[key], input, 'obj') | |
} | |
return input as Infer<T> | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment