This repository has been archived by the owner on Jul 16, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathindex.ts
419 lines (380 loc) · 12.2 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
/**
* This library reifies typescript types so that we can validate them at runtime.
*
* The idea was heavily inspired by https://gcanti.github.io/io-ts/
*/
type TOpts = { logFailures: boolean };
export type Schema<TInner> = (value: unknown, opts?: TOpts) => value is TInner;
type ExtractTypeguard<T> = T extends (v: unknown, o?: TOpts) => v is infer U
? U
: never;
export type TypeOf<A extends Schema<unknown>> = ExtractTypeguard<A>;
type OptionalSchemaKeys<TSchemaMap> = {
[Index in keyof TSchemaMap]: Schema<undefined> extends TSchemaMap[Index]
? Index
: never;
}[keyof TSchemaMap];
type NonOptionalSchemaKeys<TSchemaMap> = {
[Index in keyof TSchemaMap]: Schema<undefined> extends TSchemaMap[Index]
? never
: Index;
}[keyof TSchemaMap];
export type UnwrapSchemaMap<TSchemaMap> = keyof TSchemaMap extends never
? Record<string | number | symbol, undefined>
: {
[SchemaMapIndex in OptionalSchemaKeys<TSchemaMap>]?: TSchemaMap[SchemaMapIndex] extends Schema<unknown>
? TypeOf<TSchemaMap[SchemaMapIndex]>
: never;
} & {
[SchemaMapIndex in NonOptionalSchemaKeys<TSchemaMap>]: TSchemaMap[SchemaMapIndex] extends Schema<unknown>
? TypeOf<TSchemaMap[SchemaMapIndex]>
: never;
};
export const undefinedtype = (value: unknown): value is undefined => {
return typeof value === "undefined";
};
// Have to clothe the type so that boolean isn't distrubuted
// I.e. type Extends<A, B> = A extends B ? true : false;
// results in:
// Extends<boolean, true> = Extends<true, true> | Extends<false, true> = true | false
type Extends<A, B> = [A] extends [B] ? true : false;
type NonGenericExceptBooleans<A> = true extends
| Extends<string, A>
| Extends<number, A>
? never
: A;
type NonGeneric<A> = true extends Extends<boolean, A>
? never
: NonGenericExceptBooleans<A>;
function literalWithoutGenericCheck<TInner>(inner: TInner) {
return (value: unknown): value is TInner => {
// Not entirely correct, b/c you could have a literal dict, but should work for strings, numbers (might be weird around floats), and booleans
return JSON.stringify(inner) === JSON.stringify(value);
};
}
export function literal<TInner>(
inner: NonGeneric<TInner>
): (value: unknown) => value is NonGeneric<TInner> {
return literalWithoutGenericCheck(inner);
}
// JSON Types
export const number = (value: unknown): value is number => {
return typeof value === "number";
};
export const string = (value: unknown): value is string => {
return typeof value === "string";
};
export const boolean = (value: unknown): value is boolean => {
return typeof value === "boolean";
};
export const nulltype = (value: unknown): value is null => {
return value === null;
};
/**
* Note: I'm explicitly excluding dictionaries from this library.
* I have seen very few legitimate uses of dictionaries in API design
* and more common than not, the use case is better served by a shape
* or an array.
*/
/**
* If we define the below schema and extracted type:
*
* const mySchema = shape({requiredKey: string, optionalKey: optional(string)})
* type TMySchema = TypeOf<typeof mySchema>
*
* We want the following to work:
*
* const myObject: TMySchema = {requiredKey: 'value'}
*
* This doesn't work out of the box with our definition of optional.
* FixOptionalIndices defined below gives us this.
*/
type OptionalIndices<T> = {
[Index in keyof T]: undefined extends T[Index] ? Index : never;
}[keyof T];
type RequiredIndices<T> = {
[Index in keyof T]: undefined extends T[Index] ? never : Index;
}[keyof T];
type FixOptionalIndices<T> = {
[OIndex in OptionalIndices<T>]?: T[OIndex];
} & {
[RIndex in RequiredIndices<T>]: T[RIndex];
};
export function shape<
TDefnSchema extends {
[key: string]: Schema<unknown>;
}
>(schema: TDefnSchema) {
return (
value: unknown,
opts?: TOpts
): value is FixOptionalIndices<{
[DefnIndex in keyof TDefnSchema]: TypeOf<TDefnSchema[DefnIndex]>;
}> => {
return shapeValidatorImpl(schema, value, opts);
};
}
/**
* This validator is equivalent to shape, except that it gives a generated type
* that requires explicitly setting optional keys to undefined instead of
* omitting them when they are not present. It exists for certain cases
* where the typescript compiler is not smart enough to do what we want
* with inferred types of generics.
*/
export function _shapeWithExplicitOptionals<
TDefnSchema extends {
[key: string]: Schema<unknown>;
}
>(schema: TDefnSchema) {
return (
value: unknown,
opts?: TOpts
): value is {
[DefnIndex in keyof TDefnSchema]: TypeOf<TDefnSchema[DefnIndex]>;
} => {
return shapeValidatorImpl(schema, value, opts);
};
}
const shapeValidatorImpl = <
TDefnSchema extends {
[key: string]: Schema<unknown>;
}
>(
schema: TDefnSchema,
value: unknown,
opts?: TOpts
) => {
// This explicitly allows additional keys so that the validated object
// can be intersected with other shape types (i.e. value is a superset of schema)
return (
typeof value === "object" &&
value !== null && // one of my fave JS-isms: typeof null === "object"
Object.keys(schema).every((key: string) => {
const childMatches =
schema[key] &&
schema[key]((value as Record<string, unknown>)[key], opts);
if (!childMatches && opts?.logFailures) {
console.log(
`Member of shape ${JSON.stringify(
(value as Record<string, unknown>)[key]
)} for ${key} does not match expected type`
);
}
return childMatches;
})
);
};
type TDefnSchemasForTags<TTag extends string> = {
[TagValueLiteral in string]: {
[key: string]: Schema<unknown>;
} & { [tag in TTag]: Schema<TagValueLiteral> };
};
type TaggedUnionDefn<
TTag extends string,
TDefnSchemas extends TDefnSchemasForTags<TTag>
> = {
[TagValueLiteral in keyof TDefnSchemas]: FixOptionalIndices<{
[ShapeItemKey in keyof TDefnSchemas[TagValueLiteral]]: TypeOf<
TDefnSchemas[TagValueLiteral][ShapeItemKey]
>;
}>;
}[keyof TDefnSchemas];
export function taggedUnion<
TTag extends string,
TDefnSchemas extends TDefnSchemasForTags<TTag>
>(tag: TTag, schemas: TDefnSchemas) {
return (
value: unknown,
opts?: TOpts
): value is TaggedUnionDefn<TTag, TDefnSchemas> => {
if (typeof value !== "object" || value === null || !(tag in value)) {
if (opts?.logFailures) {
console.log(`Tagged union is not an object or missing tag`);
}
return false;
}
const schema = schemas[(value as Record<string, string>)[tag]];
if (!schema) {
return false;
}
return Object.keys(schema).every((key) => {
const childMatches =
schema[key] &&
schema[key]((value as Record<string, unknown>)[key], opts);
if (!childMatches && opts?.logFailures) {
console.log(
`Member of shape ${JSON.stringify(
(value as Record<string, unknown>)[key]
)} for ${key} does not match expected type`
);
}
return childMatches;
});
};
}
export function array<TMember>(member: Schema<TMember>) {
return (value: unknown, opts?: TOpts): value is TMember[] => {
return (
Array.isArray(value) &&
value.every((v) => {
const childMatches = member(v, opts);
if (!childMatches && opts?.logFailures) {
console.log(
`Member of array "${JSON.stringify(
v
)}" does not match expected type`
);
}
return childMatches;
})
);
};
}
// Not technically called out in JSON, but we can use JSON for this
/**
* Note: when defining a tuple you should specify the schema array `const`
*
* const tupleSchema = t.tuple([t.string, t.number] as const);
*
* If you don't, validation will work, but `t.typeOf` will be too permissive.
*
* This is due to a limitation of our current (simple) implementation.
* We can eventually get to a world where this isn't required.
*/
export function tuple<
TDefnSchema extends readonly Schema<unknown>[]
// Mapped tuple logic derived from https://stackoverflow.com/a/51679156/781199
>(schema: TDefnSchema) {
return (
value: unknown,
opts?: TOpts
): value is {
[DefnIndex in keyof TDefnSchema]: TDefnSchema[DefnIndex] extends Schema<unknown>
? TypeOf<TDefnSchema[DefnIndex]>
: never;
} & { length: TDefnSchema["length"] } => {
return (
Array.isArray(value) &&
value.length === schema.length &&
value.every((v, idx) => {
const childMatches = schema[idx](v, opts);
if (!childMatches && opts?.logFailures) {
console.log(
`Member of tuple "${JSON.stringify(
v
)}" does not match expected type`
);
}
return childMatches;
})
);
};
}
// Typescript Nonsense
export function union<TLeft, TRight>(
left: Schema<TLeft>,
right: Schema<TRight>
) {
return (value: unknown, opts?: TOpts): value is TLeft | TRight => {
const matches = left(value, opts) || right(value, opts);
if (!matches && opts?.logFailures) {
console.log(
`Member of union "${JSON.stringify(
value
)}" does not match expected type`
);
}
return matches;
};
}
export function intersection<TLeft, TRight>(
left: Schema<TLeft>,
right: Schema<TRight>
) {
return (value: unknown, opts?: TOpts): value is TLeft & TRight => {
const matches = left(value, opts) && right(value, opts);
if (!matches && opts?.logFailures) {
console.log(
`Member of intersection "${JSON.stringify(
value
)}" does not match expected type`
);
}
return matches;
};
}
export function unionMany<TSchema extends Schema<unknown>>(inner: TSchema[]) {
return (value: unknown, opts?: TOpts): value is TypeOf<TSchema> => {
const matches = inner.some((schema) => {
return schema(value, opts);
});
if (!matches && opts?.logFailures) {
console.log(
`Member of union-many ${JSON.stringify(value)} does not expected type`
);
}
return matches;
};
}
// Check out https://stackoverflow.com/a/50375286/781199 for how we're
// leveraging distributive conditionals and inference from conditionals
type TPluralIntersectionType<T> = (
T extends Schema<infer U> ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;
export function intersectMany<TSchema extends Schema<unknown>>(
inner: TSchema[]
) {
return (
value: unknown,
opts?: TOpts
): value is TPluralIntersectionType<TSchema> => {
const matches = inner.every((schema) => {
return schema(value, opts);
});
if (!matches && opts?.logFailures) {
console.log(
`Member of intersection-many ${JSON.stringify(
value
)} does not expected type`
);
}
return matches;
};
}
// Helpers
export function literals<TLiterals>(
// See below for why we exclude booleans here
inners: readonly NonGenericExceptBooleans<TLiterals>[]
): (
value: unknown,
opts?: TOpts | undefined
) => value is NonGenericExceptBooleans<TLiterals> {
return unionMany(
inners.map((value) => {
// Note: we intentionally don't use literal(). The reason is that would
// break literals([true, false] as const)
//
// This is because when we call literal() we need to know what type it's being
// called with (b/c there is only one lambda in the map), and so logically that
// type is inferred as the union of all members of the array. When we pass this function
// an array that contains both true and false, the type of literal is infered as
// literal<true|false>(value: NonGeneric<true|false>), our mistake catching code in
// NonGeneric detects that boolean extends true|false and flags it (assuming you simply
// called literal() without as const, e.g. literal(false) errors b/c it gets infered to
// literal<boolean>(false))
return literalWithoutGenericCheck(value);
})
);
}
export function optional<TInner>(
inner: Schema<TInner>
): (value: unknown, opts?: TOpts | undefined) => value is TInner | undefined {
return union(inner, undefinedtype);
}
export function nullable<TInner>(
inner: Schema<TInner>
): (value: unknown, opts?: TOpts | undefined) => value is TInner | null {
return union(inner, nulltype);
}