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 21 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.

@@ -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'

@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.

Copy link
Contributor Author

@infomiho infomiho Nov 22, 2024

Choose a reason for hiding this comment

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

This breaks the build for old users who have started their Wasp project before TS 5.5. was released.

Are you sure it breaks the build if we just bump the TS version in package.json? Shouldn't it just install a newer TS version for them?

EDIT: the users have their TS version defined in their package.json. Yep. But we could introduce validation for the typescript package as well, so we could force them to bump it :)

But, I think the isNotNull solution is more in the scope of this PR.

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

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 fixed this instance by provided an additional overload :)

waspc/examples/todoApp/src/testTypes/operations/client.ts Outdated Show resolved Hide resolved
@@ -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.

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 now updated the types so user can't be null since we control the hook execution it makes sense to expose this kind of API to the end user.

@@ -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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The trick is that this is happening in an arrow function - TS can't ensure that you checked that user exists. I've now created an userId variable outside of the arrow fn and it's okay.

@@ -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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated to use FindAuthWithUserResult 👍

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