Created
January 5, 2025 19:01
-
-
Save tunnckoCore/6ded8d40e989090c41eefd67eb11e1dc to your computer and use it in GitHub Desktop.
typescript string validation with proper both compile-time and runtime checking
This file contains 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
// eventually | |
// import type { StandardSchemaV1 } from '@standard-schema/spec'; | |
// import { SchemaError } from '@standard-schema/utils'; | |
// Utility types for length checking | |
type Length<T extends string, Counter extends number[] = []> = T extends `${string}${infer Tail}` | |
? Length<Tail, [...Counter, 0]> | |
: Counter['length']; | |
type Compare< | |
First extends number, | |
Second extends number, | |
Counter extends number[] = [], | |
> = First extends Second | |
? 'equal' | |
: Counter['length'] extends First | |
? 'less' | |
: Counter['length'] extends Second | |
? 'greater' | |
: Compare<First, Second, [...Counter, 0]>; | |
// Types for various constraints | |
type MinLength<T extends string, Min extends number> = | |
Compare<Min, Length<T>> extends 'less' | 'equal' ? T : never; | |
type MaxLength<T extends string, Max extends number> = | |
Compare<Length<T>, Max> extends 'less' | 'equal' ? T : never; | |
type Contains<T extends string, Sub extends string> = T extends `${string}${Sub}${string}` | |
? T | |
: never; | |
type StartsWith<T extends string, Prefix extends string> = T extends `${Prefix}${string}` | |
? T | |
: never; | |
type EndsWith<T extends string, Suffix extends string> = T extends `${string}${Suffix}` ? T : never; | |
type EmailString<T extends string> = T extends `${string}@${string}.${string}` ? T : never; | |
type URLString<T extends string> = | |
T extends `${'http' | 'https'}://${'localhost' | string}${`:${string}` | `.${string}`}` | |
? T | |
: never; | |
// const stringValidator = <T extends string = string>() => ({ | |
// min: <Min extends number>(min: Min, message?: string) => ({ | |
// validate: <TT extends T>(str: MinLength<TT, Min>): MinLength<TT, Min> => { | |
// if (str.length < min) { | |
// throw new Error(`String length (${str.length}) is less than minimum (${min})`); | |
// } | |
// return str as MinLength<TT, Min>; | |
// }, | |
// }), | |
// max: <Max extends number>(max: Max, message?: string) => ({ | |
// type: 'string', | |
// message, | |
// '~standard': { | |
// version: 1, | |
// vendor: 'foo, | |
// validate: <TT extends T>( | |
// str: MaxLength<TT, Max>, | |
// ): { value: MaxLength<TT, Max> } | { issues: { message: string }[] } => { | |
// if (str.length > max) { | |
// return { | |
// issues: [ | |
// { | |
// message: message | |
// ? message | |
// .replaceAll('{actual}', String(str.length)) | |
// .replaceAll('{expected}', String(max)) | |
// : `String length (${str.length}) exceeds maximum (${max})`, | |
// }, | |
// ], | |
// }; | |
// } | |
// return { value: str } as { value: MaxLength<TT, Max> }; | |
// }, | |
// }, | |
// }), | |
// contains: <Sub extends string>(sub: Sub) => ({ | |
// validate: <TT extends T>(str: Contains<TT, Sub>): Contains<TT, Sub> => { | |
// if (!str.includes(sub)) { | |
// throw new Error(`String does not contain "${sub}"`); | |
// } | |
// return str as Contains<TT, Sub>; | |
// }, | |
// }), | |
// startsWith: <Prefix extends string>(prefix: Prefix) => ({ | |
// validate: <TT extends T>(str: StartsWith<TT, Prefix>): StartsWith<TT, Prefix> => { | |
// if (!str.startsWith(prefix)) { | |
// throw new Error(`String does not start with "${prefix}"`); | |
// } | |
// return str as StartsWith<TT, Prefix>; | |
// }, | |
// }), | |
// endsWith: <Suffix extends string>(suffix: Suffix) => ({ | |
// validate: <TT extends T>(str: EndsWith<TT, Suffix>): EndsWith<TT, Suffix> => { | |
// if (!str.endsWith(suffix)) { | |
// throw new Error(`String does not end with "${suffix}"`); | |
// } | |
// return str as EndsWith<TT, Suffix>; | |
// }, | |
// }), | |
// email: () => ({ | |
// validate: <TT extends T>(str: EmailString<TT>): EmailString<TT> => { | |
// if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str)) { | |
// throw new Error('String is not a valid email format'); | |
// } | |
// return str as EmailString<TT>; | |
// }, | |
// }), | |
// url: { | |
// validate: <TT extends T>(str: URLString<TT>): URLString<TT> => { | |
// try { | |
// const _ = new URL(str); | |
// } catch { | |
// throw new Error('String is not a valid URL'); | |
// } | |
// return str as URLString<TT>; | |
// }, | |
// }, | |
// }); | |
const stringValidator = <T extends string = string>() => ({ | |
min: <Min extends number>(min: Min, message?: string) => ({ | |
type: 'string', | |
message, | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>(str: MinLength<TT, Min>): MinLength<TT, Min> => { | |
if (str.length < min) { | |
// return { | |
// issues: [ | |
// { | |
// message: message | |
// ? message | |
// .replaceAll('{actual}', String(str.length)) | |
// .replaceAll('{expected}', String(min)) | |
// : `String length (${str.length}) is less than minimum (${min})`, | |
// }, | |
// ], | |
// }; | |
throw new Error('foo bar min length'); | |
} | |
return str as MinLength<TT, Min>; | |
// return { value: str } as { value: MinLength<TT, Min> }; | |
}, | |
}, | |
}), | |
max: <Max extends number>(max: Max, message?: string) => ({ | |
type: 'string', | |
message, | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>( | |
str: MaxLength<TT, Max>, | |
): { value: MaxLength<TT, Max> } | { issues: { message: string }[] } => { | |
if (str.length > max) { | |
return { | |
issues: [ | |
{ | |
message: message | |
? message | |
.replaceAll('{actual}', String(str.length)) | |
.replaceAll('{expected}', String(max)) | |
: `String length (${str.length}) exceeds maximum (${max})`, | |
}, | |
], | |
}; | |
} | |
return { value: str } as { value: MaxLength<TT, Max> }; | |
}, | |
}, | |
}), | |
contains: <Sub extends string>(sub: Sub, message?: string) => ({ | |
type: 'string', | |
message, | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>( | |
str: Contains<TT, Sub>, | |
): { value: Contains<TT, Sub> } | { issues: { message: string }[] } => { | |
if (!str.includes(sub)) { | |
return { | |
issues: [ | |
{ | |
message: message ?? `String does not contain "${sub}"`, | |
}, | |
], | |
}; | |
} | |
return { value: str } as { value: Contains<TT, Sub> }; | |
}, | |
}, | |
}), | |
startsWith: <Prefix extends string>(prefix: Prefix, message?: string) => ({ | |
type: 'string', | |
message, | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>( | |
str: StartsWith<TT, Prefix>, | |
): { value: StartsWith<TT, Prefix> } | { issues: { message: string }[] } => { | |
if (!str.startsWith(prefix)) { | |
return { | |
issues: [ | |
{ | |
message: message ?? `String does not start with "${prefix}"`, | |
}, | |
], | |
}; | |
} | |
return { value: str } as { value: StartsWith<TT, Prefix> }; | |
}, | |
}, | |
}), | |
endsWith: <Suffix extends string>(suffix: Suffix, message?: string) => ({ | |
type: 'string', | |
message, | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>( | |
str: EndsWith<TT, Suffix>, | |
): { value: EndsWith<TT, Suffix> } | { issues: { message: string }[] } => { | |
if (!str.endsWith(suffix)) { | |
return { | |
issues: [ | |
{ | |
message: message ?? `String does not end with "${suffix}"`, | |
}, | |
], | |
}; | |
} | |
return { value: str } as { value: EndsWith<TT, Suffix> }; | |
}, | |
}, | |
}), | |
email: (message?: string) => ({ | |
type: 'string', | |
message, | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>( | |
str: EmailString<TT>, | |
): { value: EmailString<TT> } | { issues: { message: string }[] } => { | |
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str)) { | |
return { | |
issues: [ | |
{ | |
message: message ?? 'String is not a valid email format', | |
}, | |
], | |
}; | |
} | |
return { value: str } as { value: EmailString<TT> }; | |
}, | |
}, | |
}), | |
url: (message?: string) => ({ | |
type: 'string', | |
message, | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>( | |
str: URLString<TT>, | |
): { value: URLString<TT> } | { issues: { message: string }[] } => { | |
try { | |
const _ = new URL(str); | |
return { value: str } as { value: URLString<TT> }; | |
} catch { | |
return { | |
issues: [ | |
{ | |
message: message ?? 'String is not a valid URL', | |
}, | |
], | |
}; | |
} | |
}, | |
}, | |
}), | |
}); | |
// First, let's define some utility types | |
// type ValidationResult<T> = { value: T } | { issues: { message: string }[] }; | |
// type Validator<T> = { | |
// '~standard': { | |
// validate: (value: unknown) => ValidationResult<T>; | |
// }; | |
// }; | |
// Pipe function implementation | |
const pipe = <T extends string = string>(...validators: any) => ({ | |
type: 'string', | |
'~standard': { | |
version: 1, | |
vendor: 'foo', | |
validate: <TT extends T>(value: TT): StandardSchemaV1.Result<TT> => { | |
const issues = [] as StandardSchemaV1.Issue[]; | |
for (const vali of validators) { | |
const result = vali['~standard'].validate(value) as StandardSchemaV1.Result<unknown>; | |
if ('issues' in result) { | |
issues.push(...(result.issues as StandardSchemaV1.Issue[])); | |
} else { | |
value = result.value as any; | |
} | |
} | |
if (issues.length > 0) { | |
return { issues }; | |
} | |
return { value: value as TT }; | |
}, | |
}, | |
}); | |
// Usage example | |
const { min, max, contains, startsWith, endsWith, email, url } = stringValidator(); | |
// These should error at compile-time or runtime if constraints are not met | |
// const result1 = min(5).validate('hello'); // Should pass | |
// const result2 = min(10).validate('hello'); // Compile-time error | |
// const result3 = max(5).validate('hello'); // Should pass | |
// const result4 = contains('ell').validate('hello'); // Should pass | |
// const result15 = contains('bar').validate('hello'); // Should error (no "bar" in "hello") | |
// const result5 = startsWith('h').validate('hello'); // Should pass | |
// const result16 = startsWith('foo').validate('hello'); // Should error (no "foo" at start) | |
// const result6 = endsWith('o').validate('hello'); // Should pass | |
// const result7 = email.validate('[email protected]'); // Should pass | |
// const result8 = email.validate('invalid-email'); // Compile-time AND Runtime error | |
// const result9 = url.validate('http://example.com'); // Should pass | |
// const result10 = url.validate('https://foobar.com'); // Should pass | |
// const result11 = url.validate('http://localhost'); // Should error | |
// const result12 = url.validate('http://localhost:3000'); // Should pass | |
// const result13 = url.validate('https://fooqux'); // Should error (no TLD) | |
// const result14 = url.validate('invalid-url'); // Compile-time AND Runtime error | |
// const foobieSchema = pipe(min(5), max(10), startsWith('foo'), endsWith('bar')); | |
// foobieSchema['~standard'].validate('sasa'); | |
// const schema = { | |
// name: min(5), | |
// email: email(), | |
// }; | |
// type Schema = StandardSchemaV1.InferOutput<typeof min(5)>; | |
// const foo: Schema = { | |
// name: 'sa', | |
// email: 'sasa', | |
// }; | |
const foo = [min(5), max(10), startsWith('foo')]; | |
type Foo = { | |
[K in keyof typeof foo]: ReturnType<(typeof foo)[K]['~standard']['validate']>; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment