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

Enables strict null checks in SDK #2360

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open

Conversation

infomiho
Copy link
Contributor

@infomiho infomiho commented Oct 28, 2024

This PR enables strictNullChecks option in the SDK tsconfig.json.

We want to enable this option to make sure Zod schemas are working properly before we start using them for env variables validation.

Left to do

  • Rough changes to get it to work
  • Go through changes one more time
    • Keep changes that are straightforward and long term
    • Add @ts-ignore and a TODO for things that need more work

Using @ts-ignore is not that problematic since it will unblock us for using Zod schemas, but also explicitly mark parts of the code base that need some work. This work was still needed before this PR, but it was implicit.

@@ -302,7 +302,7 @@ function AdditionalFormFields({
disabled={isLoading}
/>
{errors[field.name] && (
<FormError>{errors[field.name].message}</FormError>
<FormError>{errors[field.name]!.message}</FormError>
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'm not sure why this is needed since we have the check errors[field.name] && (?

@@ -69,7 +69,7 @@ async function getAuthUserData(userId: {= userEntityUpper =}['id']): Promise<Aut
throwInvalidCredentialsError()
}

return createAuthUserData(user);
return createAuthUserData(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.

Again, I'm not sure why this is needed since we have the check if (!user) {. Maybe it's not obvious to the compiler that the throwInvalidCredentialsError fn throws?

const { data: task, isLoading } = tasksCrud.get.useQuery({
id: parseInt(id, 10),
});
const { id } = useParams<{ id: string }>()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not related to this PR, but a quick fix since id is of type string | undefined.

@@ -18,7 +18,7 @@ import {
// Details here: https://github.com/wasp-lang/wasp/issues/2017
export function makeQueryCacheKey<Input, Output>(
query: Query<Input, Output>,
payload: Input
payload?: Input
Copy link
Contributor Author

@infomiho infomiho Oct 28, 2024

Choose a reason for hiding this comment

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

payload !== undefined hints at payload being optional

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure this is equivalent to what we had. Perhaps Input can be undefined and we only want this check to fire then (i.e., we never call this function without a payload).

Can you double check?

@@ -1,9 +1,9 @@
{{={= =}=}}
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 started to work on enabling strict null checks while working on env variables, so these are some changes I left from that effort - env -> nodeEnv since I imagined that the import of env vars would look like import { env } from 'wasp/server'

@@ -93,4 +93,5 @@ type ClientOperationWithNonAnyInput<Input, Output> =
? (args?: unknown) => Promise<Output>
: [Input] extends [void]
? () => Promise<Output>
: (args: Input) => Promise<Output>
// TODO: decide if this is what we want?
: (args?: Input) => Promise<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 get the error:

client/operations/hooks.ts(32,26): error TS2345: Argument of type 'Input | undefined' is not assignable to parameter of type 'Input'.

The useQuery hook definition:

function useQuery<Input, Output>(
  query: Query<Input, Output>,
  queryFnArgs?: Input,
  options?: any
): UseQueryResult<Output, Error>

has the queryFnArgs as an optional argument: and it executes query(queryFnArgs), query is:

query: Query<Input, Output>

which is in the end (args: Input) => Promise<Output>.

This leads me to set the args as optional here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@sodic alternativelly, I can just put a @ts-ignore and not modify anything related to operations, so we can deal with this in the future?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't fully understand what happened here, but our type tests should cover this. If you can change it without breaking them, then it should be fine.

That said, I'm 99% sure this change introduces a bug that the tests will catch. If they fail to catch it, then I'll look into it some more.

@infomiho infomiho changed the title WIP: Enables strict null checks in SDK Enables strict null checks in SDK Oct 28, 2024
@infomiho infomiho requested a review from sodic October 28, 2024 15:04
@infomiho infomiho mentioned this pull request Oct 29, 2024
5 tasks
@@ -88,10 +88,10 @@ export function handleApiError(error: AxiosError<{ message?: string, data?: unkn
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
return new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did we change this function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we use handleApiError(error) which throws the error in some fn - TS thinks that fn has undefined as one of the return values.

If we do throw handleApiError(error) in the same fn, TS now knows this throws and then the fn doesn't return i.e. it doesn't return undefined.

export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
export function createInvalidCredentialsError(message?: string): HttpError {
return new HttpError(401, 'Invalid credentials', { message })
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did we change this one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same reasoning as here: #2360 (comment)

@@ -6,37 +6,37 @@ const EMAIL_FIELD = 'email';
const TOKEN_FIELD = 'token';

// PUBLIC API
export function ensureValidEmail(args: unknown): void {
export function ensureValidEmail<Args extends object>(args: Args): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe the type parameter is unnecessary. It's enough to say:

ensureValidEmail(args: object) ...

See item 51 of Effective TypeScript for an explanation.

The same applies to all functions in the file.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch! Read Item 51 and it makes sense 👍

Comment on lines 48 to +49
const identities = Object.values(data.identities).filter(Boolean);
return identities.length > 0 ? identities[0].id : null;
return identities.length > 0 ? identities[0]!.id : null;
Copy link
Contributor

Choose a reason for hiding this comment

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

We should remove the assertion and change the filtering predicate:

const identities = Object.values(data.identities).filter(identity => identity !== null);
return identities.length > 0 ? identities[0].id : null;

Three benefits:

  1. TypeScript can now infer a type predicate so the ! on the next line is no longer necessary. It's both safer and less fragile (the entire type procedure is localized and no longer spans two lines).
  2. identity => identity !== null is cleaner than Boolean anyway. The first is clear to everyone, and the second only to those familiar with JS's quirks and idioms.

Note

Type predicate inference is a pretty new feature. It's available in TS 5.5, and our package.json requires ^5.1.0. This breaks the build for old users who have started their Wasp project before TS 5.5. was released.

We can either update TS to ^5.5, or we can define an isNotNull type guard as a named function and use that instead of a Boolean. It behaves the same way but requires more ceremony.

The first option requires users to migrate stuff, while the second one doesn't. So it all depends on whether we want to release this as part of a minor or a major release.

Comment on lines 26 to 27
// TODO: figure out why overloading is not working
// @ts-ignore
Copy link
Contributor

Choose a reason for hiding this comment

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

I am strongly in favor of resolving all these issues now. It's unlikely we'll ever prioritize returning to them, and even if we did, we'd be missing context.

Figuring this stuff out is a part of this task and is a little annoying (that's mostly why I postponed it :)), but I believe it should be done.

I'd be more in favor of the "we'll figure it out later approach" if:

  • The issue was about something else and these postponed fixes were tangential stuff.
  • Merging this was a blocker for a critical bugfix.

But since figuring this stuff out is the central point of the issue, and since closing the issue isn't a blocker for a critical bugfix, I don't think postponing is justified.

To be clear, I'm not against banning ts-ignores altogether. But if we're using them, we should know why. Dirty hacks are sometimes ok, as long as we understand what we're doing and can document it.

Adding a hack we don't understand with a "figure this out later" todo is an extreme measure, and should be reserved reserved for production-is-down-and-were-losing-millions situations, with a clear plan on when to come back to it.

So, in short, I don't think we should add tsignores we don't understand (I may be convinced otherwise). We can meet in person and go through these issues together.

Copy link
Contributor Author

@infomiho infomiho Nov 20, 2024

Choose a reason for hiding this comment

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

As we discussed IRL: this PR has the goal of unblocking the #2362 PR and nothing will substantially change in the codebase - besides quick fixes and enabling the strict null checking option. It will have the effect that Zod works as expected and all future code has to be written with strict null checks. All the things that were "broken" until this point - will remain broken (in the eyes of Typescript).

I'd go through all the @ts-ignores and do one of the following:

  • if we can fix it now - fix it
  • if the fix requires going down a rabbit hole - make an issue which we will mention in a comment
  • use type assertion instead of @ts-ignore if we can - makes the override more precise

Comment on lines 55 to 57
Expect<Equal<typeof boolToStringAuth, (payload?: boolean) => Promise<string>>>,
Expect<Equal<typeof boolToVoidNoAuth, (payload?: boolean) => Promise<void>>>,
Expect<Equal<typeof boolToVoidAuth, (payload?: boolean) => Promise<void>>>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, this is what I suspected had to happen when I saw the change in the templates 😄

Anyway, we shouldn't introduce a regression in user space to make internal TS happy.

@@ -29,7 +29,7 @@ export const onBeforeLogin: OnBeforeLoginHook = async ({ providerId }) => {

export const onAfterLogin: OnAfterLoginHook = async ({ prisma, user }) => {
await prisma.user.update({
where: { id: user.id },
where: { id: user!.id },
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, how come we need this? Shouldn't the hook enforce the user code in its type signature?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are right, the hook should demand that user exists and the end user shouldn't be exposed to this.

@@ -73,7 +73,7 @@ export const toggleAllTasks: ToggleAllTasks = async (_args, context) => {

const whereIsDone = (isDone: boolean) => ({
isDone,
user: { id: context.user.id },
user: { id: context.user!.id },
Copy link
Contributor

Choose a reason for hiding this comment

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

This one is surprising too. How come TS can't infer this from the check we did above?

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'm not sure, I was surprised as well, but it wouldn't narrow the type for me - for some reason. We can talk about this IRL.

@@ -100,7 +100,7 @@ type OnBeforeLoginHookParams = {
/**
* User that is trying to log in.
*/
user: Awaited<ReturnType<typeof findAuthWithUserBy>>['user']
user: NonNullable<Awaited<ReturnType<typeof findAuthWithUserBy>>>['user']
Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, it's a little weird that this type is defined in terms of a function's return value. Also, how come the NonNullable part is only now coming into the picture?

How come it's not defined using the User type (and the function's return type would then also be defined using the User type).

Have we discussed something like this already (defining values in terms of types and vice versa)? I'm having a Deja Vu but can't find it.

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 have a FindAuthWithUserResult type that we didn't export - we should export it and use it instead of the return type 👍

NonNullable came into play because findAuthWithUserBy had the wrong return type of User and not User | null which is logical - user maybe doesn't exist in the DB.

Comment on lines +13 to +14
username: process.env.SMTP_USERNAME!,
password: process.env.SMTP_PASSWORD!,
Copy link
Contributor

Choose a reason for hiding this comment

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

This file is throwing all kinds of errors for me. I tried to build the server with TypeScript (npx tsc in .wasp/out/server) and it failed.

Does this work for you normally (I'm guessing it does, because you wouldn't know these types needed changing if it didn't).

I probably messed something up because it also fails on older versions of main. If everything's normal for you, I'll dig in.

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.

2 participants