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] && (?

Copy link
Contributor

Choose a reason for hiding this comment

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

Because, theoretically, the errors object could have a getter (a computed property) implemented like this:

get someFieldName () {
    return Math.random() > 0.5 ? undefined : { message: "You're lucky this time" }
}

In more precise terms, object field access is a function call and can easily be impure.

To get around this, you can define a local fieldError variable above the JSX and then use that.

Copy link
Contributor

@sodic sodic Nov 24, 2024

Choose a reason for hiding this comment

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

Ok, fun fact, my reasoning was wrong. TypeScript will happily allow this error to happen. Check it out.

Looks like TS specifically doesn't like if you do it twice (access a field of one object to get a string for accessing the field of another object). But my suggestion above still works. See here.

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, I see this is taken care of now (because you're throwing explicitly.

Maybe it's not obvious to the compiler that the throwInvalidCredentialsError fn throws?

Yeah, that was it.

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, yeah, saw the other comment that addresses that. Makes sense.

I can't believe I'm saying this, but this is why monads are nice (don't tell Martin).

You can resolve this when you read it.

waspc/data/Generator/templates/sdk/wasp/auth/validation.ts Outdated Show resolved Hide resolved
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.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice change. I would recommend merging the predicates file with the types file (in universal). Seems like they belong together.

And no, I don't know how to call it 😄

(but you don't need to do anything, decide whatever you want and resolve the comment)

waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts Outdated Show resolved Hide resolved
waspc/examples/todoApp/src/testTypes/operations/client.ts Outdated Show resolved Hide resolved
waspc/headless-test/examples/todoApp/src/auth/hooks.ts Outdated Show resolved Hide resolved
waspc/headless-test/examples/todoApp/src/server/actions.ts Outdated Show resolved Hide resolved
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.

Comment on lines +68 to +70
if (!authIdentity) {
throw new Error(`User with email: ${email} not found.`);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Will this cause a breaking change for users (Hyrum's law)?

We should fix it of course, just make sure to add a note to the changelog if it does. You can resolve the comment if and when you take care of it.

);
}

function serializeProviderData<PN extends ProviderName>(providerData: PossibleProviderData[PN]): string {
return JSON.stringify(providerData);
}

async function sanitizeProviderData<PN extends ProviderName>(
async function ensurePasswordIsHashed<PN extends ProviderName>(
Copy link
Contributor

Choose a reason for hiding this comment

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

Did we make an issue for fixing this in the end?

The field name for hashedPassword doesn't allow for a correct type signature for this function and it can therefore be called multiple times?

If I got it right, we should rename password to hashedPassword where appropriate, create a new type, rename this function, and change it's type to be A -> B instead of A -> A?

): PossibleProviderData[PN] {
// NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON.
let data = JSON.parse(providerData) as PossibleProviderData[PN];
return JSON.parse(providerData) as PossibleProviderData[PN];
Copy link
Contributor

Choose a reason for hiding this comment

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

You might not need the as here. I think the function's return type already narrows unknown to PossibleProviderData[PN].

@@ -261,34 +275,45 @@ export async function validateAndGetUserFields(
}

// PUBLIC API
export function deserializeAndSanitizeProviderData<PN extends ProviderName>(
export function getProviderData<PN extends ProviderName>(
Copy link
Contributor

Choose a reason for hiding this comment

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

This was a breaking change, right? We should update the changelog and the public API table.

}

// PUBLIC API
export type CreateUserResult = {= userEntityUpper =} & {
Copy link
Contributor

Choose a reason for hiding this comment

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

Also a new addition it seems. We should:

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

@sodic sodic Nov 24, 2024

Choose a reason for hiding this comment

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

Ok, fun fact, my reasoning was wrong. TypeScript will happily allow this error to happen. Check it out.

Looks like TS specifically doesn't like if you do it twice (access a field of one object to get a string for accessing the field of another object). But my suggestion above still works. See here.

Copy link
Contributor

@sodic sodic left a comment

Choose a reason for hiding this comment

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

Nice! I'm approving.

Just make sure you cover these before merging:

I've fixed my part, left some comments explaining it, and gone through at your fixes.

I didn't run the e2e tests yet because you'll probably make a few more changes.

return null;
}

return result as FindAuthWithUserResult;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think preferrable options are:

const result = await prisma.auth.findFirst({ where, include: { user: true } });
return result && result.user && { ...result, user: result.user };

Or:

const result = await prisma.auth.findFirst({ where, include: { user: true } });

if (!result) {
  return null;
}

const { user, ...auth } = result;
return user && { user, ...auth };

I prefer the second one.

Comment on lines +31 to +36
// FIXME: query fns don't handle the `undefined` case correctly
// https://github.com/wasp-lang/wasp/issues/2017
queryKey: makeQueryCacheKey(query, (queryFnArgs as Input)),
// FIXME: query fns don't handle the `undefined` case correctly
// https://github.com/wasp-lang/wasp/issues/2017
queryFn: () => query(queryFnArgs as Input),
Copy link
Contributor

Choose a reason for hiding this comment

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

Added the details into the issue: #2017 (comment)

@@ -138,7 +142,7 @@ type InternalOptimisticUpdateDefinition<ActionInput, CachedData> = {
* the current state of the cache and returns the desired (new) state of the
* cache.
*/
type SpecificUpdateQuery<CachedData> = (oldData: CachedData) => CachedData;
type SpecificUpdateQuery<CachedData> = (oldData: CachedData | undefined) => CachedData;
Copy link
Contributor

Choose a reason for hiding this comment

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

Also covered in #2017 (comment)

@@ -259,10 +263,10 @@ function makeRqOptimisticUpdateOptions<ActionInput, CachedData>(
);

// We're using a Map to correctly serialize query keys that contain objects.
const previousData = new Map();
const previousData: Map<QueryKey, CachedData | undefined> = new Map();
Copy link
Contributor

Choose a reason for hiding this comment

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

Tl;dr everything about this part of the code is covered in #2017 (comment), but it turns out this was actually correct (but would be nicer if we handled it more explicitly).

@@ -71,9 +71,10 @@ export const toggleAllTasks: ToggleAllTasks = async (_args, context) => {
throw new HttpError(401)
}

const userId = 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.

Noice.

Comment on lines +81 to 102

getJobScheduleData =
maybe
(object ["isDefined" .= False])
( \schedule ->
object
[ "isDefined" .= True,
"cron" .= J.cron schedule,
"args" .= getJobScheduleArgs (J.args schedule),
"options" .= getJobSchduleOptions (J.scheduleExecutorOptionsJson job)
]
)
getJobScheduleArgs =
maybe
(object ["isDefined" .= False])
(\args -> object ["isDefined" .= True, "json" .= Aeson.Text.encodeToLazyText args])

getJobSchduleOptions =
maybe
(object ["isDefined" .= False])
(\options -> object ["isDefined" .= True, "json" .= Aeson.Text.encodeToLazyText options])

Copy link
Contributor

Choose a reason for hiding this comment

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

Looks great! Just please double check we didn't break anything (does the e2e test look alright to you).

We could add a few "complicated" job declarations to the job e2e test.

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