Skip to content
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

Merged
merged 23 commits into from
Apr 6, 2023
Merged

Conversation

sodic
Copy link
Contributor

@sodic sodic commented Mar 30, 2023

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:

export const getPerson: GetPerson<{ id: number }, { name: string, dob: Date }> = ({ id }, context) => {
  // You do some processing and return a value of type { name: string, dob: Date }.
  // All is well with the type checker.
}

Then, you do this on the client:

import getPerson from '@wasp/queries/getPerson'

const person = getPerson({ id: 12 })

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:

  1. Your query returns a value of type { name: string, dob: Date }, so far so good
  2. Experss middleware serializes it into a JSON, but JSON serializes Dates into strings
  3. The payload is sent over the network.
  4. Axios deserializes the payload back into an object of type { name: string, dob: string }, but the type checker things you have an object of type { name: string, dob: Date }.
  5. Chaos

Todo list:

  • Ensure proper serialization/deserialization to ensure types match runtime types
  • Clean up the Haskell code and reorganize it
  • Implement full-stack type safety for actions
  • Discuss whether server imports are a problem or not (could be creating technical debt)
  • Require operation Input and Output types to extend superjson's serializable type

@infomiho infomiho self-requested a review April 3, 2023 12:19
Comment on lines 4 to 5
// todo(filip): turn into a proper import
import { type SanitizedUser } from '../../../server/src/_types/'
Copy link
Contributor Author

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?

Copy link
Contributor

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: remove trailing/

Comment on lines +6 to +11
const query = createQuery<{= operationTypeName =}>(
'{= queryRoute =}',
{=& entitiesArray =},
)

export default query
Copy link
Contributor Author

@sodic sodic Apr 6, 2023

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:

image

When exporting the variable after the assignment:

image

Comment on lines +12 to +16
// 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
Copy link
Contributor Author

@sodic sodic Apr 6, 2023

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
Copy link
Contributor Author

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.

Copy link
Contributor

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>(
Copy link
Contributor Author

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.

Comment on lines +9 to +11
type QueryFor<BackendQuery extends GenericBackendQuery> = Expand<
Query<Parameters<BackendQuery>[0], _Awaited<ReturnType<BackendQuery>>>
>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applying Expand to this type results in a better DX.

On-hover with Expand:

image

On-hover without Expand:

image

Comment on lines +10 to +13
export type Action<Input, Output> =
[Input] extends [never] ?
(args?: unknown) => Promise<Output> :
(args: Input) => Promise<Output>
Copy link
Contributor Author

@sodic sodic Apr 6, 2023

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:

  1. If the Input type is never (i.e. the user hasn't specified an input type), the action should take a single optional argument of type unknown.
  2. If the Input type isn't never (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.

Copy link
Contributor

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

Copy link
Contributor Author

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) {
Copy link
Contributor Author

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)
Copy link
Contributor Author

@sodic sodic Apr 6, 2023

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, {
Copy link
Contributor Author

@sodic sodic Apr 6, 2023

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)) || {}
Copy link
Contributor Author

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.

Copy link
Contributor

@infomiho infomiho Apr 6, 2023

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)) || {}
Copy link
Contributor Author

@sodic sodic Apr 6, 2023

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:

  1. When deserializing, Express will turn '{"json": "...", "meta": "..." }' (string) to { json: "<serialized_data>", meta: ".<metadata>" } (object) , and we'll do the rest with superjsonDeserialize. The end result is data.
  2. When serializing, we will turn data into { json: "<serialized_data>", meta: ".<metadata>" } (object) using superjsonSerialize, and Express will do the rest. The end result is '{"json": "<serialized_data>", "meta": "<metadata>" }' (string).

@sodic sodic requested review from Martinsos and removed request for Martinsos April 6, 2023 13:53
@sodic sodic marked this pull request as ready for review April 6, 2023 14:12
Comment on lines +25 to +26
import Wasp.Generator.ServerGenerator.Common (serverSrcDirInServerRootDir)
import Wasp.Generator.ServerGenerator.OperationsG (operationFileInSrcDir)
Copy link
Member

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)
/**
Copy link
Contributor

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

Copy link
Contributor Author

@sodic sodic Apr 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid

image

No time unfortunately.

@@ -40,14 +41,15 @@ export function mockServer(): {
})
afterAll(() => server.close())

function mockQuery({ route }: { route: QueryRoute }, resJson: any): void {
const mockQuery : MockQuery = (query, mockData) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit

Suggested change
const mockQuery : MockQuery = (query, mockData) => {
const mockQuery: MockQuery = (query, mockData) => {

@@ -65,3 +67,9 @@ export function mockServer(): {

return { server, mockQuery }
}

type InternalQuery<Input, Output> = {
Copy link
Contributor

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?

Copy link
Contributor Author

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}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit

Suggested change
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user: SanitizedUser}>
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user: SanitizedUser }>

Comment on lines 16 to 18
router.{= routeMethod =}(
'{= routePath =}',
handleRejection(
Copy link
Contributor

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like in the good old PHP websites.
Screenshot 2023-04-06 at 16 55 28

@@ -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 })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

@@ -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",
Copy link
Contributor

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. 🤷‍♂️

Copy link
Contributor Author

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 😄

Copy link
Contributor Author

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.

Comment on lines +13 to +20
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
Copy link
Contributor Author

@sodic sodic Apr 6, 2023

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) {
Copy link
Contributor Author

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.

Copy link
Contributor

@shayneczyzewski shayneczyzewski left a 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 🥳 🚀

Copy link
Contributor

@infomiho infomiho left a 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 🤯

@sodic
Copy link
Contributor Author

sodic commented Apr 6, 2023

I'm merging to get RC out asap, but will take a look at what else to do tomorrow.

@sodic sodic merged commit 945d777 into main Apr 6, 2023
@sodic sodic deleted the filip-full-stack-type-safety branch April 6, 2023 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Automatic client-side support for operation inputs and outputs
4 participants