-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Full-stack compile-time type safety #1090
Conversation
waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs
Outdated
Show resolved
Hide resolved
waspc/src/Wasp/Generator/WebAppGenerator/OperationsGenerator.hs
Outdated
Show resolved
Hide resolved
// todo(filip): turn into a proper import | ||
import { type SanitizedUser } from '../../../server/src/_types/' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@infomiho Is this something that we should be handling differently after you've implemented the new importing system?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't really need to use the new import style, it only helps you to define a JS import statement in Haskell and then print it in a JS file.
If want to ensure that this import is importing from the proper place (server/src) you can Strong Path it and define a new JSImport
and then output it in the template. This would move the information about this weird import in Haskell, which would be a win for us IMHO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: remove trailing/
const query = createQuery<{= operationTypeName =}>( | ||
'{= queryRoute =}', | ||
{=& entitiesArray =}, | ||
) | ||
|
||
export default query |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although we decided to abandon default exports (see #546 for details), we're still postponing it. It requires many breaking changes in our API (and the docs).
In the meantime, I separated the export from its definition because it helps with VS Code's "on hover" feature.
When exporting the value directly:
When exporting the variable after the assignment:
// TypeScript's native Awaited type exhibits strange behavior in VS Code (see | ||
// todo for details). Until it's fixed, we're using our own type for this. | ||
export type _Awaited<T extends Promise<unknown>> = T extends Promise<infer V> | ||
? V | ||
: never |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Take a look at this playground and try the same snippet in VS Code. This strange behavior causes a problem for operations with unspecified inputs.
Example
Let's say your backend query definition looks like this (i.e., input and output types are omitted):
export const getTasks: GetTasks = async (_args, context) => {
// ...
}
This is what we want TypeScript to infer on the client:
Unfortunately, if we use the built-in Awaited
type, this happens:
@@ -26,10 +26,11 @@ export function renderInContext(ui: ReactElement): RenderResult { | |||
} | |||
|
|||
type QueryRoute = Query<any, any>['route'] | |||
type MockQuery = <Input, Output, MockData extends Output>(query: Query<Input, Output>, resJson: MockData) => void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@shayneczyzewski I've changed this signature to improve type safety. Check this playground for the demo.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good to me, thanks!
} | ||
|
||
export function useQuery<Input, Output, Error = unknown>( | ||
export function useQuery<Input, Output>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We always throw something that resembles a general Error
, so let's keep it simple for now.
type QueryFor<BackendQuery extends GenericBackendQuery> = Expand< | ||
Query<Parameters<BackendQuery>[0], _Awaited<ReturnType<BackendQuery>>> | ||
> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export type Action<Input, Output> = | ||
[Input] extends [never] ? | ||
(args?: unknown) => Promise<Output> : | ||
(args: Input) => Promise<Output> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To understand why we need a conditional type here and why it looks the way it looks, we need to establish a couple of things.
Default input and output types for Operations
Users can specify their operation's Input
and Output
types like this:
export const updateTaskIsDone: UpdateTask<Pick<Task, "id">, Task> = async ({ id }, context) => {
// ...
}
We also want to allow them to omit input and output types. Sure, they won't get full-stack type safety, but it helps rapid prototyping:
export const updateTaskIsDone: UpdateTask = async (_args, context) => {
// ...
}
The main question is: What should be the default input and output types?
Assuming we don't want to use any
unless absolutely necessary, the correct answer is: never
for the input, and unknown
for the output. Read #989 (comment) to understand why.
Frontend behavior with omitted input types
Assuming the user decides not to specify input and output types:
export const updateTaskIsDone: UpdateTask = async (_args, context) => {
// ...
}
We want the client-side RPC call to accept either nothing or anything. More precisely, all following calls should pass the type checker:
updateTaskIsDone()
updateTaskIsDone(12)
updateTaskIsDone("asdf")
To implement such behavior, we need to detect whether a backend action's Input
type is never
and type the frontend action's argument appropriately:
- If the
Input
type isnever
(i.e. the user hasn't specified an input type), the action should take a single optional argument of typeunknown
. - If the
Input
type isn'tnever
(i.e., the user has specified an input type), the action should simply use the user-specified input type.
Disabling the distribution of never
All that's left to answer is Why do we wrap the types inside an array?
The following type wouldn't work (playground link):
export type Action<Input, Output> =
Input extends never ?
(args?: unknown) => Promise<Output> :
(args: Input) => Promise<Output>
type X = Action<never, number> // X is 'never', not (args?: unknown) => Promise<number>
This is because conditional types (in our case, Action
) distribute over unions (in our case, the empty union never
). We need a way to prevent the distribution, and that's what the array wrapper is for.
Read microsoft/TypeScript#31751 (comment) for more details.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I appreciate the depth of this comment. The TS is still a bit of magic to me that enables it, but I appreciate how much thought went into this! :D
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly the same as former _action.js
, but Git didn't detect it as a rename.
@@ -1,12 +1,14 @@ | |||
import { useQuery } from '../queries' | |||
import api, { handleApiError } from '../api' | |||
import { HttpMethod } from '../types' | |||
// todo(filip): turn into a proper import | |||
import { type SanitizedUser } from '../../../server/src/_types/' | |||
|
|||
export default function useAuth(queryFnArgs, config) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've decided not to go with the module boundaries principle (wasp-lang/vscode-wasp#6 (comment)) to avoid unnecessary verbosity.
This short function that's basically a wrapper to two other (explicitly typed) public functions: useQuery
and getMe
. If that changes, we'll make the type signature explicit.
const response = await api.post(operationRoute.path, args) | ||
return response.data | ||
const superjsonArgs = superjsonSerialize(args) | ||
const response = await api.post(operationRoute.path, superjsonArgs) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're still relying on axios
to fully serialize a "semi-serialized" superjson object under the hood. This happens for both the request and the response.
More precisely, Axios will turn { json: "...", meta: "..." }
(object) into '{"json": "...", "meta": "..." }'
(string).
The same thing happens on the server. I describe the process in more detail there: #1090 (comment)
@@ -7,7 +7,7 @@ import prisma from '../dbClient.js' | |||
consider in the future if it is worth removing this duplication. =} | |||
|
|||
export default async function (args, context) { | |||
return {= jsFn.importIdentifier =}(args, { | |||
return {= jsFn.importIdentifier =}(args as never, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is pretty ridiculous, but is the quickest possible way to fix a type issue caused by the requirements described here: #1090 (comment).
We'll come up with a more long-term solution eventually.
Todo: make an issue
{=& operationImportStmt =} | ||
|
||
export default handleRejection(async (req, res) => { | ||
const args = req.body || {} | ||
const args = (req.body && superjsonDeserialize(req.body)) || {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO: I should probably be catching something here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe write a helper function called deserializeRequestBody
or similar so it's more readable.
{=& operationImportStmt =} | ||
|
||
export default handleRejection(async (req, res) => { | ||
const args = req.body || {} | ||
const args = (req.body && superjsonDeserialize(req.body)) || {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Our deserialization process on the server is analogous to the one on the client: #1090 (comment)
More precisely:
- When deserializing, Express will turn
'{"json": "...", "meta": "..." }'
(string) to{ json: "<serialized_data>", meta: ".<metadata>" }
(object) , and we'll do the rest withsuperjsonDeserialize
. The end result isdata
. - When serializing, we will turn
data
into{ json: "<serialized_data>", meta: ".<metadata>" }
(object) usingsuperjsonSerialize
, and Express will do the rest. The end result is'{"json": "<serialized_data>", "meta": "<metadata>" }'
(string).
import Wasp.Generator.ServerGenerator.Common (serverSrcDirInServerRootDir) | ||
import Wasp.Generator.ServerGenerator.OperationsG (operationFileInSrcDir) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe import these qualified in a way that makes it clear this is coming from Server. For serverSrcDirInServerRootDir
it is clear, but for operationFileInSrcDir
it is not.
@@ -11,7 +11,7 @@ const updateHandlers = makeUpdateHandlersMap(hashQueryKey) | |||
/** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: maybe convert this to Typescript, it doesn't seem too complex
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -40,14 +41,15 @@ export function mockServer(): { | |||
}) | |||
afterAll(() => server.close()) | |||
|
|||
function mockQuery({ route }: { route: QueryRoute }, resJson: any): void { | |||
const mockQuery : MockQuery = (query, mockData) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit
const mockQuery : MockQuery = (query, mockData) => { | |
const mockQuery: MockQuery = (query, mockData) => { |
@@ -65,3 +67,9 @@ export function mockServer(): { | |||
|
|||
return { server, mockQuery } | |||
} | |||
|
|||
type InternalQuery<Input, Output> = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We are hiding the route
from user?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, for the time being, reasoning here: #1055 (comment) (spoiler: not much of a reasoning)
@@ -72,20 +73,11 @@ type Context<Entities extends _Entity[]> = Expand<{ | |||
}> | |||
|
|||
{=# isAuthEnabled =} | |||
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & UserInContext> | |||
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user: SanitizedUser}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user: SanitizedUser}> | |
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user: SanitizedUser }> |
router.{= routeMethod =}( | ||
'{= routePath =}', | ||
handleRejection( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change loses the auth middleware (old line 18), which will put the user
into the context
. Without it, when you go to the dashboard you get the following error:
Server(stderr): TypeError: Cannot read properties of undefined (reading 'username')
Server(stderr): at fooBar (file:///home/shayne/dev/wasp/waspc/examples/todoApp/.wasp/out/server/dist/ext-src/apis.js:3:44)
Server(stderr): at file:///home/shayne/dev/wasp/waspc/examples/todoApp/.wasp/out/server/dist/routes/apis/index.js:14:12
Server(stderr): at file:///home/shayne/dev/wasp/waspc/examples/todoApp/.wasp/out/server/dist/utils.js:15:15
// compiler, but expands the type's representatoin in IDEs (i.e., inlines all | ||
// type constructors) to make it more readable for the user. | ||
// | ||
// It expands this SO answer to funcitons: https://stackoverflow.com/a/57683652 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// It expands this SO answer to funcitons: https://stackoverflow.com/a/57683652 | |
// It expands this SO answer to functions: https://stackoverflow.com/a/57683652 |
|
||
return ( | ||
<div className="app border-spacing-2 p-4"> | ||
<header className="flex justify-between"> | ||
<h1 className="font-bold text-3xl mb-5"> | ||
<Link to="/">ToDo App</Link> | ||
</h1> | ||
<h2> | ||
Your site was loaded at: {date?.toLocaleString()} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -13,9 +13,9 @@ type TaskPayload = Pick<Task, "id" | "isDone"> | |||
const Todo = (props: any) => { | |||
const taskId = parseInt(props.match.params.id) | |||
|
|||
const { data: task, isFetching, error } = useQuery<Pick<Task, "id">, Task>(getTask, { id: taskId }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice
waspc/examples/todoApp/todoApp.wasp
Outdated
@@ -29,7 +29,7 @@ app todoApp { | |||
setupFn: import setup from "@server/serverSetup.js" | |||
}, | |||
client: { | |||
rootComponent: import { App } from "@client/App.jsx", | |||
rootComponent: import { App } from "@client/App", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We wanted to keep the extensions in the main.wasp
so it's not confusing that we MUST include them in backend but they are OPTIONAL in the client. 🤷♂️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changing it back... and the worst thing is: I think it was my suggestion 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Most of the changes in the file are prettier formatting.
export type Route = { method: HttpMethod; path: string } | ||
|
||
export type MockQuery = <Input, Output, MockOutput extends Output>( | ||
query: Query<Input, Output>, | ||
resJson: MockOutput | ||
) => void | ||
|
||
export type MockApi = (route: Route, resJson: unknown) => void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've decided to export these guys because the rule of thumb for exporting types is: If users can dig it out themselves (e.g., using utility types like Parameters
), make it public.
|
||
export default function useAuth(queryFnArgs, config) { | ||
export default function useAuth(queryFnArgs?: unknown, config?: any) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like useQuery
will send queryFnArgs
to getMe
, but getMe
doesn't expect them so they end up ignored.
Todo: make an issue for this or investigate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Functionally looks good to me! Great job @sodic! Much of the TS is still over my head, but I can see the benefits of this already. :D 🥳 🚀
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, it's really cool to get type in FE just like that 🤯
I'm merging to get RC out asap, but will take a look at what else to do tomorrow. |
Fixes #976.
To keep TypeScript from lying to the user, we had to add a data serialization/transformation layer. I've decided to use
superjson
for the time being (thanks @Zeko369!).In the future, I hope we can make this transformer configurable (as tRPC does).
Motivating example
To understand why we had to use Superjson, let's see what would happen if we implemented #976 without it.
Imagine you had the following query:
Then, you do this on the client:
TypeScript will tell you that
person
has type{ name: string, dob: Date }
. This is probably the type you wanted, but not the one you actually have:{ name: string, dob: Date }
, so far so goodDate
s into strings{ name: string, dob: string }
, but the type checker things you have an object of type{ name: string, dob: Date }
.Todo list:
Input
andOutput
types to extend superjson's serializable type