Last active
January 14, 2021 10:10
-
-
Save rtpg/32b89e24b205d80874877b8cb968b81f to your computer and use it in GitHub Desktop.
Assert the shape of your endpoint responses
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
/** | |
* First, we're going to declare two types to use for tagging validation, with a function to "run" during compile-time | |
* | |
* validate(a: T,b: U) returns Validated<T> if a and b mutually extend each other, if not it returns NotValidated<U> | |
* | |
* (by sending the left value in one case and the right value in another the type system is more likely to give "real" | |
* error messages and tell you what keys you are missing) | |
* | |
* Usage is: | |
* // this should fail at compile time based on the used values | |
* let validatedValue: Validated<any> = validate(valueToCheck, representativeValue); | |
* | |
* // if you have no representative value, just casting null into the desired interface works | |
* // since you don't use the values | |
* let validateOffOfInterface: Validated<SomeInterface> = validate(realValue, {} as SomeInterface); | |
* | |
* // this shou | |
* doSomethingWithValue(unwrapValidated(validatedValue)) | |
*/ | |
// these types aren't meant to actually be used, and this actual dictionary isn't used, but this is how you get Typescript | |
// to complain in a structurally-typed world | |
type Validated<T> = {__validated_type: T, __valid: true}; | |
type NotValidated<T> = {__validated_type: T, __valid: false}; | |
export function validate<T, U>(t: T, u:U): T extends U? U extends T? Validated<T>: NotValidated<U>: NotValidated<U> { | |
return t as any; | |
} | |
// this lets you get the original type out (no-op in practice) | |
export function unwrapValidated<T>(t: Validated<T>): T { | |
return t as any; | |
} | |
// this fails type checking with | |
// Type 'NotValidated<{ a: number; c: number; }>' is not assignable to type 'Validated<any>' | |
// let v1: Validated<any> = validate({a: 1, b:2}, {a: 3, c:4}); | |
// When validating, if you specify what you're validating against, then you'll get better error messages | |
// this fails type checking with | |
// Type 'Validated<{ b: number; }>' is not assignable to type 'Validated<{ a: number; }>'. | |
// Property 'a' is missing in type '{ b: number; }' but required in type '{ a: number; }'. | |
// let v2: Validated<{a: number}> = validate({b: 2}, {b:3}); | |
// we don't need to build representative types here either | |
interface SomeType { | |
a: number; | |
b: string; | |
c: { | |
d: string; | |
e: string; | |
} | |
} | |
// this fails with NotValidated. Unfortunately it doesn't point to what the extra keys are | |
// let v4: Validated<any> = validate({a: 1, b: "hi", c: {d: "some", e: "string", f: "extra key"}}, {} as SomeType); | |
// if you specify what you're validating against, though, then you will get much better error messages | |
// this fails with | |
// Type 'NotValidated<{ a: number; }>' is not assignable to type 'Validated<SomeType>'. | |
// Types of property '__validated_type' are incompatible. | |
// Type '{ a: number; }' is missing the following properties from type 'SomeType': b, c | |
// let v5: Validated<SomeType> = validate({} as SomeType, {a: 1}); | |
// extra keys get detected here | |
// let v6: Validated<{a: number, b: number}> = validate({a: 1, b:2} as {a: number, b:number}, {a: 3, b:4, c: 5}); | |
/** | |
* Here's a basic example of how to build some routing based off of this, where you end up enforcing your shapes | |
*/ | |
interface HTTPReq { | |
GET: any; | |
} | |
interface PurchaseShape { | |
purchaseId: number; | |
description: string; | |
} | |
interface UserShape { | |
userId: number; | |
username: string; | |
} | |
function getPurchase(id: number){ | |
// this function returns extra keys for some reason | |
return { | |
purchaseId: id, | |
description: "Hi there", | |
extraKey: "oops" | |
} | |
} | |
function getPurchaseNoExtraKey(id: number){ | |
// this one matches the endpoint shape we're aiming for | |
return { | |
purchaseId: id, | |
description: "Hi there", | |
} | |
} | |
export function purchaseEndpoint(request: HTTPReq){ | |
// didn't type annotate but if I did it would be a `NotValidated` result | |
return validate(getPurchase(request.GET.id), {} as PurchaseShape); | |
} | |
export function purchaseEndpoint2(request: HTTPReq){ | |
// this doesn't return a validation result at all, just the unwrapped object | |
return getPurchase(request.GET.id); | |
} | |
export function purchaseEndpointNoExtraKey(request: HTTPReq){ | |
// this function returns the purchase without extra keys... | |
let purchase = getPurchaseNoExtraKey(request.GET.id); | |
// and properly validates it against `PurchaseShape` | |
return validate(purchase, {} as PurchaseShape); | |
} | |
// each endpoint takes a Shape to declare what its result should look like | |
interface Endpoint<Shape>{ | |
handler: (r: HTTPReq) => Validated<Shape>, | |
route: string | |
} | |
function makeEndpoint<Shape>( | |
handler: (r:HTTPReq) => Validated<Shape>, | |
route: string | |
): Endpoint<Shape> { | |
/** | |
* Though this function just builds a dictionary, the fact that it's a function can mean that you | |
* are given one more opportunity to declare what your endpoint's type should be | |
*/ | |
return {handler, route}; | |
} | |
let endpoints: Endpoint<any>[] = [ | |
// will fail from being a handler return NotValidated | |
// makeEndpoint(purchaseEndpoint, "purchases"), | |
// this will fail from not being a handler returning a validation result | |
// makeEndpoint(purchaseEndpoint2, "purchases2"), | |
// this will fail because this returns validated purchases, not users | |
// (a similar issue would happen if you had validated inside the endpoint code with UserShape) | |
// makeEndpoint<UserShape>(purchaseEndpointNoExtraKey, "purchases3"), | |
// this one passes as it returns purchases and does all the stuff | |
makeEndpoint<PurchaseShape>(purchaseEndpointNoExtraKey, "purchases4"), | |
] | |
function handleRequest(request: HTTPReq){ | |
// route the request somehow to get the endpoint to use | |
let endpoint = endpoints[2], | |
// here request will be of type Validated<any> | |
result = endpoint.handler(request), | |
// if you need to unwrap it you can just pass it through unwrapValidated | |
unwrappedRes = unwrapValidated(result); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
(I believe this was written in response to this tweet) but I can't remember exactly...