Issue
Most of the time for me, dynamic check is needed for verification of fetch response. And i was thinking, can this be done with user defined typeguard in a generic way for any type of object with multiple props and additional checks, so it can be used something like:
Here is an example with sample object, but i want a function without it.
// ================= shared exported =================
type Writer = {
name: string
age: number
}
type Book = {
id: number
name: string
tags: string[] | null
writers: Writer[]
}
// function to check object with multiple props general shape, to not do it by hand
function ofType<T>(obj: any): obj is T {
if (!obj) return false;
// how to?
return true // or false
}
// ================= used and defined in components =================
function isBook(obj: any): obj is Book {
if (!ofType<Book>(obj)) return false //checking for shape and simple types
// cheking for specific values and ranges
if (obj.id < 1) return false
if (obj.writers && obj.writers.some(( { age } )=> age < 5 || age > 150)) return false
return true
}
const book = {
id: 1,
name: 'Avangers',
tags: ['marvel', 'fun'],
writers: [ {name: 'Max', age: 25}, {name: 'Max', age: 25}]
}
console.log(isBook(book)) // true or false
Solution
TypeScript's type system is erased when compiled to JavaScript. That implies any effort to use the standard tsc
compiler by itself to generate runtime type guards from type
or interface
definitions will not succeed; there's nothing of these definitions left at runtime for you to use. So ofType<T>()
cannot be implemented.
So what can you do?
If you're willing to use some other compilation step in your build system, you can write or use a transformer that makes type guards for you from these definitions before they are erased. For example, typescript-is
will do this.
Or you could use class
definitions instead; this makes checking easy at runtime (just use instanceof
) but the hard part is deserializing JSON into a class instance and catching errors upon deserialization without writing this yourself manually. All this does is move your problem from implementing ofType<Book>(someObj)
to implementing myDeserializerFunction(Book, someObj)
where Book
is a class constructor.
Here at least you can use decorators and class metadata to generate the code needed for programmatic deserialization. You can write this yourself, or use an existing library such as json2typescript
.
Finally, you might decide to start with the type guards and let TypeScript infer your type
definitions from them. That is, instead of defining Book
and hoping to get a type guard bookGuard()
from it, you write the type guard bookGuard()
and define Book
in terms of typeof bookGuard
.
This type guard could be built by composing existing simpler type guards together, so it looks more like a declarative type definition than a data-checking function. You can write this yourself, or use an existing library such as io-ts
.
For this approach, it's instructive to look at how one might write such a library. Here's one possible implementation:
export type Guard<T> = (x: any) => x is T;
export type Guarded<T extends Guard<any>> = T extends Guard<infer V> ? V : never;
const primitiveGuard = <T>(typeOf: string) => (x: any): x is T => typeof x === typeOf;
export const gString = primitiveGuard<string>("string");
export const gNumber = primitiveGuard<number>("number");
export const gBoolean = primitiveGuard<boolean>("boolean");
export const gNull = (x: any): x is null => x === null;
export const gObject =
<T extends object>(propGuardObj: { [K in keyof T]: Guard<T[K]> }) =>
(x: any): x is T => typeof x === "object" && x !== null &&
(Object.keys(propGuardObj) as Array<keyof T>).
every(k => (k in x) && propGuardObj[k](x[k]));
export const gArray =
<T>(elemGuard: Guard<T>) => (x: any): x is Array<T> => Array.isArray(x) &&
x.every(el => elemGuard(el));
export const gUnion = <T, U>(tGuard: Guard<T>, uGuard: Guard<U>) =>
(x: any): x is T | U => tGuard(x) || uGuard(x);
Here we are exporting a few type guards and functions which compose existing type guards. The gString()
, gNumber()
, gBoolean()
, and gNull()
functions are just type guards, while gObject()
, gArray()
, and gUnion()
take existing type guards to make new type guards out of them. You can see how gObject()
takes an object full of type guard properties and makes a new type guard where each property is checked against the corresponding guard. You could add other composition functions like gIntersection()
or gPartial()
, but the ones here are enough for your example.
Now your Book
and Writer
definitions look like this (assume the above has been imported as namespace G
):
const _gWriter = G.gObject({
name: G.gString,
age: G.gNumber,
});
interface Writer extends G.Guarded<typeof _gWriter> { }
const gWriter: G.Guard<Writer> = _gWriter;
const _gBook = G.gObject({
id: G.gNumber,
name: G.gString,
tags: G.gUnion(G.gArray(G.gString), G.gNull),
writers: G.gArray(gWriter)
})
interface Book extends G.Guarded<typeof _gBook> { }
const gBook: G.Guard<Book> = _gBook;
If you squint at that you'll see that it's analogous to your example Writer
and Book
definitions. But in our case the fundamental objects are type guards gWriter
and gBook
and the types Writer
and Book
are derived from them. And then you can use gBook
directly instead of the non-existent ofType<Book>()
:
const book = JSON.parse('{"id":1,"name":"Avangers","tags":["marvel","fun"],' +
'"writers":[{"name":"Max","age":25},{"name":"Max","age":25}]}');
if (gBook(book)) {
console.log(book.name.toUpperCase() + "!"); // AVANGERS!
}
Okay, hope that helps; good luck!
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.