How to use numeric user id with next-auth
Asked Answered
S

2

9

I recently found and started testing create-t3-app as a new base for NextJS projects because it takes care of a lot of boilerplate setup for things like TypeScript, trpc, prisma, and next-auth so it'd save me a ton of time. While that's relevant, I don't think it's the source of my problem. My problem is that I use a MySQL database with auto-incrementing user IDs but it seems like the types for the next-auth package are forcing me to use a string (DefaultUser in next-auth/core/types.d.ts and AdapterUser in next-auth/adapters.d.ts both set the type of id to string and the comments say UUIDs). Thinking that I could possibly extend what's there to support numeric user IDs, I added this to next-auth.d.ts:

import { DefaultSession, DefaultUser } from 'next-auth'

declare module 'next-auth' {
  interface User extends DefaultUser {
    id: number;
  }
  interface Session {
    user?: {
      id: number;
    } & DefaultSession['user'];
  }
}

Which seems to work most places except in [...nextauth].ts where it is giving me this error

Type 'string | number' is not assignable to type 'number'. Type 'string' is not assignable to type 'number'.ts(2322)

On the session.user.id = user.id line in this section of code

export const authOptions: NextAuthOptions = {
  // Include user.id on session
  callbacks: {
    session({ session, user }) {
      if (session.user) {
        session.user.id = user.id
      }
      return session
    }
  },
  adapter: PrismaAdapter(prisma),
  providers: []
}

export default NextAuth(authOptions)

The TypeScript error goes away if I delete the id: string; line from the AdapterUser from next-auth/adapters.d.ts which then falls back to id: number; because I set that on User

export interface AdapterUser extends User {
    id: string; // <-- I removed this
    email: string;
    emailVerified: Date | null;
}

I don't think I should have to modify the library's types to support numeric user IDs but I'm all out of ideas how to solve this and haven't found answers online. Or should I not be using numeric IDs even though my database does? If it helps, I'm using

next 12.3.1
next-auth 4.12.3
react 18.2.0
react-dom 18.2.0
superjson 1.9.1
zod 3.18.0
eslint 8.22.0
postcss 8.4.14
prettier 2.7.1
prisma 4.4.0
tailwindcss 3.1.6
typescrypt 4.8.0
Siphonophore answered 20/10, 2022 at 23:48 Comment(8)
Why not stringify the ids?Hagen
@caTS Seemed seemed as hacky as my current solution with extra operations especially considering the documentation and comments suggest the ids are intended to be UUIDs.Siphonophore
@DillanWilding UUID are string like "123e4567-e89b-12d3-a456-426614174000". Did you try to declare User.id as number | string and update comparison with ... = parseInt(user.id, 10)Grimaldo
@Grimaldo I know what UUIDs are. The problem isn't that the id on User isn't numeric (or strings), it's that the TypeScript types next-auth has provided has propagated the user id type throughout their other types such as AdapterUser (and I think a couple other places) which is causing problems when I want to change it to a numeric value. If they didn't specify the type of id when extending User, it'd default/fallback to the one specified on User but since they say id: string; it causes problems.Siphonophore
@dillanwilding you should consider opening an issue and/or a pull request on their project beacuse there is no way to fix this without updating library's codeGrimaldo
@Grimaldo I was debating about doing that but since I'm relatively new to TypeScript and Next, I wasn't sure if I'd have anything valuable to contribute so I thought I'd check if anyone on StackOverflow could help first. Doesn't seem likely since even with putting a bounty on this question only 50 people have seen it in 4 days.Siphonophore
@DillanWilding Did you ever open an issue for this? Running into the same problem where my Prisma User id is of type number, but module augmentation only allows me to merge the default NextAuth User from id: string into id: string | number... TypeScript's problem is there's no way to overwrite an interface's properties, you can only merge definitionsCut
@Cut I had been putting it off because I didn't think many people encountered the issue and didn't want to embarrass myself with a stupid question/suggestion but I just created an issue github.com/nextauthjs/next-auth/issues/7966 Hopefully they can help us more and we can get a solution for this.Siphonophore
H
3

Unfortunately it doesn't seem possible to "patch up" or change the types the library has - even trying to change NextAuthOptions doesn't work since subsequent property definitions must have the same type. In the meantime, you'll have to use a runtime check/coercion or cast:

export const authOptions: NextAuthOptions = {
  // Include user.id on session
  callbacks: {
    session({ session, user }) {
      if (session.user) {
        if (typeof user.id !== "number") throw new Error("id should a number");
        session.user.id = user.id // OK
        // session.user.id = +user.id // more dangerous but still works
        // session.user.id = user.id as number // also dangerous
      }
      return session
    }
  },
  adapter: PrismaAdapter(prisma),
  providers: []
}
Hagen answered 29/10, 2022 at 21:6 Comment(2)
First off, thanks for following up. While this technically gets rid of the TypeScript error, I'm curious if there's a better solution -- if I don't get one, I'll award your answer the bounty as it does solve my problem just not the way I'd like. I now see the problem is that I was under the impression that if I implemented User that extended DefaultUser and specified id: number that it doesn't overwrite it, it acts as a union (i.e. string | number). Any idea if there's a way to replace the type instead of making a union?Siphonophore
@DillanWilding That's the thing - you can't. Subsequent declarations of a member must be of the exact same type; in other words, you can't make the type narrower or overwrite it. This is the best workaround until the library authors provide a way to augment their types.Hagen
C
0

Expanding on the above accepted answer, I utilized runtime typeguards to make my life a little easier.

In my case, my Prisma user had a few extra attributes as well:

model User {
  id                  Int                 @id @default(autoincrement())
  uuid                String              @unique @default(cuid())
  name                String?
  email               String?             @unique
  emailVerified       DateTime?
  image               String?
  accounts            Account[]
  categories          Category[]
  sessions            Session[]
  records             Record[]
  permissionsGranted  SharePermission[]     @relation("permissions_granted")
  permissionsReceived SharePermission[]     @relation("permissions_received")
  @@unique([id, uuid])
  @@index(fields: [id, uuid], name: "user_by_id_idx")
}

So I defined these typeguards:

import { User as PrismaUser } from '@prisma/client';
import { Session, User } from 'next-auth';

export interface IdentifiedSession extends Session {
    id: number;
    user: PrismaUser;
}

export const isNextAuthUser = (value: unknown): value is User => {
    const { id, name, email, image } = (value ?? {}) as User;
    const idIsValid = typeof id === 'number';
    const nameIsValid = typeof name === 'string' || !name;
    const emailIsValid = typeof email === 'string' || !email;
    const imageIsValid = typeof image === 'string' || !image;
    return idIsValid && nameIsValid && emailIsValid && imageIsValid;
};

export const isPrismaUser = (value: unknown): value is PrismaUser => {
    const { uuid, emailVerified } = (value ?? {}) as PrismaUser;
    const uuidIsValid = typeof uuid === 'string';
    const emailVerifiedIsEmail = emailVerified instanceof Date || !emailVerified;
    return isNextAuthUser(value) && uuidIsValid && emailVerifiedIsEmail;
};

export const isSession = (value: unknown): value is Session => {
    const { user, expires } = (value ?? {}) as Session;
    const userIsValid = isPrismaUser(user) || isNextAuthUser(user) || !user;
    const expiresIsValid = typeof expires === 'string';
    return expiresIsValid && userIsValid;
};

export const isIdentifiedSession = (value: unknown): value is IdentifiedSession => {
    const session = (value ?? {}) as IdentifiedSession;
    const sessionIsValid = isSession(session);
    const sessionUserIsValid = isPrismaUser(session.user);
    return sessionIsValid && sessionUserIsValid;
};

Then, in the session callback, I utilized one of the typeguards like so:

session: async ({ session, user }) => {
  if (!isPrismaUser(user)) {
    return session;
  }
  const identifiedSession: IdentifiedSession = {
    ...session,
    user,
    id: user.id,
  };
  return identifiedSession;
}

Which does mean that the signature of the callback is now Session | IdentifiedSession, which I handle in downstream consumers as such

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
    const session = await getSession({ req });
    if (!isIdentifiedSession(session)) {
        return {
            props: {
                records: [],
            },
        };
    }
    const records = await prisma.record.findMany({
        where: {
            reporter: {
                id: session.id,
            },
        },
        orderBy: {
            date: 'asc',
        },
        include: {
            category: {
                select: {
                    title: true,
                },
            },
        },
    });
    return {
        props: { records },
    };
};
Classroom answered 6/2, 2023 at 2:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.