Skip to content

Instantly share code, notes, and snippets.

@tunnckoCore
Created January 5, 2025 19:01
Show Gist options
  • Save tunnckoCore/6ded8d40e989090c41eefd67eb11e1dc to your computer and use it in GitHub Desktop.
Save tunnckoCore/6ded8d40e989090c41eefd67eb11e1dc to your computer and use it in GitHub Desktop.
typescript string validation with proper both compile-time and runtime checking
// 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