Skip to content

Instantly share code, notes, and snippets.

@rtpg
Last active January 14, 2021 10:10
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rtpg/32b89e24b205d80874877b8cb968b81f to your computer and use it in GitHub Desktop.
Save rtpg/32b89e24b205d80874877b8cb968b81f to your computer and use it in GitHub Desktop.
Assert the shape of your endpoint responses
/**
* 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);
}
@rtpg
Copy link
Author

rtpg commented Jan 14, 2021

(I believe this was written in response to this tweet) but I can't remember exactly...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment