Skip to content

Instantly share code, notes, and snippets.

@qpwo
Last active March 10, 2025 23:57
Show Gist options
  • Save qpwo/eb680743d9445f98642010b4afd18567 to your computer and use it in GitHub Desktop.
Save qpwo/eb680743d9445f98642010b4afd18567 to your computer and use it in GitHub Desktop.
my typescript type validator (not zod!)
/**
* 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()
/** 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