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

Ability to serialize arbitrary classes to send to client #8743

Open
HamishWHC opened this issue Jan 27, 2023 · 2 comments
Open

Ability to serialize arbitrary classes to send to client #8743

HamishWHC opened this issue Jan 27, 2023 · 2 comments
Milestone

Comments

@HamishWHC
Copy link

HamishWHC commented Jan 27, 2023

Describe the problem

My problem is that my database client returns data that includes classes, and I can't return this from my +page.server.ts file and use it in my +page.{ts,svelte}. Examples of these classes include Prisma.Decimal, model instances from Type/MikroORM, objects with extra methods returned by an extended Prisma client (see Prisma docs) or anything else that might be a class inside a deeply nested object.

Describe the proposed solution

Having read through #6008 (and the pull request), my understanding is that devalue creates JS code that recreates the JS object is it passed, and that getting the generated code to reference constructors for these classes would be difficult (that said, I have an idea for this that I'd like to propose, not certain if it'd work, see below).

Instead, what I really am looking for is the ability to hand some arbitrary data to Svelte and have it transform it to data that is compatible with devalue. i.e. I register a function (server-side) that identifies my class, then provide a function that takes an instance of this class and returns an object that is already compatible with devalue. This could then be incorporated into the type generation system (similar to param matchers) so that PageLoad functions have corrected types.

I don't think my explanation was very clear, so here's an example:

// src/routes/+page.server.ts
import {PageServerLoad} from "./$types"

export const load: PageServerLoad = () => {
	// getCurrentUser returns a User class
	return {
		user: await db.getCurrentUser()
	}
}
// src/serde/User.ts (or elsewhere)
import {Serializer, SerializerTest} from "@sveltejs/kit"
import {User} from "$lib/server/models"

// devalue can use this function to test if a non-POJO it encounters is a User object.
export const test = (v: any): v is User => v instanceof User
// If so, it uses this function to convert the class to a POJO (which devalue can serialize).
export const serialize: Serializer = (u: User) => ({id: u.id, username: u.username, email: u.email})
// src/routes/+page.ts
import {PageLoad} from "./$types";

export const load: PageLoad = ({data}) => {
	// data.user is now the object returned by our serialize function, and is typed as such.
	return {user, other: "data"}
}
Idea for deserializing classes Rich's initial explanation of using devalue in #6008 says the deserialization process is (roughly):
await import(`${url.pathname}/__data.js`);
const data = window.__data;
delete window.__data;

Could devalue be made to set window.__data to a function that can be passed functions to deserialize the data? This would avoid the issue of ensuring constructors are available to devalue's generated code. The deserialization functions could also be lazy loaded if needed.

This isn't really useful in the above example (a database model likely has server-side only functions attached to it, in which case I only want the POJO), but for custom scalars such as Prisma.Decimal, this would be quite helpful.

Alternatives considered

My current solution is to write a function that 'jsonifies' my complex objects in a type-safe-ish way (Range is from edgedb):

// Replace Non-Serializable
type RNS<T> = T extends Range<infer U>
	? { lower: U | null; upper: U | null }
	: T extends Array<infer U>
	? Array<RNS<U>>
	: T extends object
	? { [K in keyof T]: RNS<T[K]> }
	: T;

export const isRange = (obj: any): obj is Range<any> => obj instanceof Range;

export const jsonify = <T extends any>(data: T): RNS<T> => {
	if (isRange(data)) {
		return { lower: data.lower, upper: data.upper } as RNS<T>;
	} else if (data instanceof Array) {
		return data.map(jsonify) as RNS<T>;
	} else if (typeof data === "object" && data !== null) {
		return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, jsonify(v)])) as RNS<T>;
	} else {
		return data as RNS<T>;
	}
};

I can then wrap database queries in this (or add it with .then(jsonify)). This doesn't allow for deserializing classes.

I've also been looking at potentially using superjson, but I lose the ability to modify the PageData type without touching anything else - I have to modify the types that I am reporting to superjson.

Importance

nice to have

Additional Information

No response

@dummdidumm
Copy link
Member

This came up before in various variations, and we're hesitant to add something like this as it complicates the (de)serialization mechanism quite a bit. Also, what about actions? And should all get the same treatment, or is this per-load-function, or per-thing-to-(de)-serialize? It's hard to find a good answer here, which is why we have deferred this to "implement a wrapper yourself" so far. You already did one part of this, you could do the other end through running something like

/// +page.svelte
export let data; // is the jsonified thing
$: user = unjsonify<User>(data);

@benmccann benmccann changed the title Ability to pass arbitrary classes from server to client. Ability to serialize arbitrary classes to send to client Jan 27, 2023
@Rich-Harris Rich-Harris added this to the later milestone Jan 27, 2023
@HamishWHC
Copy link
Author

Good points. As a user I would expect the feature to occur for actions, and perhaps error data as well, although my gut feeling is that case would be extremely niche (and tbh I've never even tested if Date or BigInt is serialised in those either). I'm not at all familiar with Kit's internals, but I my guess was that actions and loaders share serialization logic, so that applying this to both would be not much more work (or complexity) than applying it to one. I suppose that's not the case when working with type generation?

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

No branches or pull requests

3 participants