Sharing Types and Code Between the Server and Client
Usually, server and client code shouldn't mix. This keeps client bundle sizes small and prevents unnecessary code from gumming up the server. Uncaught ReferenceError: window is not defined
, anyone?
But there are some things you definitely want to share between them - especially types and constants. And it's perfectly safe to share them, so long as you follow a few guidelines.
tl;dr
For sharing types, use import type {SharedType} from '../../shared/types'
.
For sharing constants and utility functions, export them from a file that has no imports, so it doesn't import any server or client code.
A Primer on Client and Server Bundles and the Dangers of Sharing Code
The process of taking a single codebase and creating separate compilations for both the client and server is still a relatively new idea, popularized by Next.js and server-side rendering. Frameworks and bundlers are still improving the process to make the experience seamless, efficient, and optimized.
JavaScript programs create a module graph any time they import
or export
from modules. That creates a dependency tree out of the program. When compiling an app, bundlers create separate trees for the server and the client, using rules and heuristics to decide which code should be included in each. For example, the React Router framework automatically removes loader
and action
exports from the client bundle.
Where you get into trouble is when you use client-only or server-only code in places the framework doesn't account for. Things like Node's fs
module or the browser's document.querySelector
function can't be used in the other environment - you just get errors. It's possible to shim these or add polyfills, but usually it should be avoided.
Plus, you want to keep your client bundle small, which is difficult to do if you're including a bunch of server-side code that isn't even executed on the client.
So how do you share code between the two environments without running into these problems
Sharing Types
Sharing types is easy - just make sure you only import the type from the other module. If one module only imports types from the other module, when it is compiled the import statement will be removed entirely.
// This code... import { NumberList } from './types' const myNumbers:NumberList = [1,2,3] // Becomes this... const myNumbers = [1,2,3]
Even if the imported module has side effects, it's still excluded when only types are imported.
There's a problem, though. It becomes all too easy to forget which imports are types and which are values. Or you might be importing a type that is also a value, like a class, and want to ignore the value part so it's not included in the bundle.
This is where Import Type comes in. It ensures that you are only importing types which will be removed from your code.
// Bad - this imports the class and any modules it depends on import { ServerOnlyClass } from '../server/utils' const serverSideData:ServerOnlyClass = { /* ... */ } // Good - this only imports the type, which is removed when compiled import type { ServerOnlyClass } from '../server/utils' const serverSideData:ServerOnlyClass = { /* ... */ }
Sharing Code
Code is both easier and harder to share than types. There isn't a magical directive you can add to your imports to say "only import the stuff that I want." Relying on tree-shaking for correctness usually leads to pain. In other words, it's a bad idea to expect your bundler to know exactly what stuff should go in the server build and in the client build - you need to help it.
That said, the solution is simple: Be explicit with your imports and exports. Recognize that if you share a module between the client and server, anything that module imports will also be shared.
For a list of constants, this is easy - just write the constnats in their own file with no imports. Simple utilities with no dependencies can also be easily shared with no concern.
Utilities and functions which import other files or NPM modules should be examined to make sure what they are importing is necessary in both environments. It's possible they can be used in both environments with no problems. It's also possible that you'll need to split similar looking functions into two separate files, one for the server and one for the client.
Wrapping Up
This is the upside of using TypeScript for both the client and the server - you can share code between the two - but not with impunity. Like everything in our work, you can build better by caring about your craft and understanding how the behavior of bundlers can affect how you structure your code.