diff --git a/docs/start/framework/react/guide/environment-functions.md b/docs/start/framework/react/guide/environment-functions.md index f472a3a26a3..886d21059a1 100644 --- a/docs/start/framework/react/guide/environment-functions.md +++ b/docs/start/framework/react/guide/environment-functions.md @@ -83,6 +83,28 @@ A no-op (short for "no operation") is a function that does nothing when executed function noop() {} ``` +### Restricting the type of the implementations + +You can use `.$withType()` to enforce the type of both the server and client implementations matches the same function signature. + +```tsx +import { createIsomorphicFn } from '@tanstack/react-start' +import type { Environment } from '@/types/environment' + +const getEnv = createIsomorphicFn() + .$withType<() => Environment>() + .server(() => 'server') + .client(() => 'client') + +const env = getEnv() +// ^? Environment +``` + +You will get a type error if either implementation mismatches from the function signature passed to `$withType`. + +> [!NOTE] +> When using `$withType()`, TypeScript will enforce that you define both the server and client implementations. + --- ## `env`Only Functions diff --git a/packages/start-client-core/src/createIsomorphicFn.ts b/packages/start-client-core/src/createIsomorphicFn.ts index 09a1be9ec13..4aa508f6e89 100644 --- a/packages/start-client-core/src/createIsomorphicFn.ts +++ b/packages/start-client-core/src/createIsomorphicFn.ts @@ -21,7 +21,31 @@ export interface ClientOnlyFn, TClient> ) => IsomorphicFn } +export interface ClientImplRequired, TReturnType> { + client: ( + clientImpl: (...args: TArgs) => TReturnType, + ) => IsomorphicFn +} + +export interface ServerImplRequired, TReturnType> { + server: ( + serverImpl: (...args: TArgs) => TReturnType, + ) => IsomorphicFn +} + +export interface IsomorphicFnWithType, TReturnType> { + server: ( + serverImpl: (...args: TArgs) => TReturnType, + ) => ClientImplRequired + client: ( + clientImpl: (...args: TArgs) => TReturnType, + ) => ServerImplRequired +} + export interface IsomorphicFnBase extends IsomorphicFn { + $withType: < + TFn extends (...args: Array) => any, + >() => IsomorphicFnWithType, ReturnType> server: , TServer>( serverImpl: (...args: TArgs) => TServer, ) => ServerOnlyFn @@ -34,8 +58,12 @@ export interface IsomorphicFnBase extends IsomorphicFn { // if we use `createIsomorphicFn` in this library itself, vite tries to execute it before the transformer runs // therefore we must return a dummy function that allows calling `server` and `client` method chains. export function createIsomorphicFn(): IsomorphicFnBase { - return { + const baseFns = { server: () => ({ client: () => () => {} }), client: () => ({ server: () => () => {} }), + } + return { + $withType: () => baseFns, + ...baseFns, } as any } diff --git a/packages/start-client-core/src/tests/createIsomorphicFn.test-d.ts b/packages/start-client-core/src/tests/createIsomorphicFn.test-d.ts index 89f427d8c64..b99061a8d45 100644 --- a/packages/start-client-core/src/tests/createIsomorphicFn.test-d.ts +++ b/packages/start-client-core/src/tests/createIsomorphicFn.test-d.ts @@ -70,3 +70,56 @@ test('createIsomorphicFn with arguments', () => { expectTypeOf(fn2).toBeCallableWith(1, 'a') expectTypeOf(fn2).returns.toEqualTypeOf() }) + +test('createIsomorphicFn with type', () => { + const fn1 = createIsomorphicFn() + .$withType<(a: string) => string>() + .server((a) => { + expectTypeOf(a).toEqualTypeOf() + return 'data' + }) + .client((a) => { + expectTypeOf(a).toEqualTypeOf() + return 'data' + }) + expectTypeOf(fn1).toBeCallableWith('foo') + expectTypeOf(fn1).returns.toEqualTypeOf() + + const fn2 = createIsomorphicFn() + .$withType<(a: string, b: number) => boolean>() + .client((a, b) => { + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + return true as const + }) + .server((a, b) => { + expectTypeOf(a).toEqualTypeOf() + expectTypeOf(b).toEqualTypeOf() + return false as const + }) + expectTypeOf(fn2).toBeCallableWith('foo', 1) + expectTypeOf(fn2).returns.toEqualTypeOf() + + const noServerImpl = createIsomorphicFn() + .$withType<(a: string) => string>() + .client((a) => 'data') + expectTypeOf(noServerImpl).not.toBeFunction() + + // Missing server implementation + const noClientImpl = createIsomorphicFn() + .$withType<(a: string) => string>() + .server((a) => 'data') + expectTypeOf(noClientImpl).not.toBeFunction() + + const _invalidFnReturn = createIsomorphicFn() + .$withType<(a: string) => string>() + .server(() => '1') + // @ts-expect-error - invalid return type + .client(() => 2) + + const _invalidFnArgs = createIsomorphicFn() + .$withType<(a: string) => string>() + .server((a: string) => 'data') + // @ts-expect-error - invalid argument type + .client((a: number) => 'data') +}) diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx index 837195fb9c4..de9494e8b09 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx @@ -13,4 +13,5 @@ function abstractedClientFn() { } const clientOnlyFnAbstracted = abstractedClientFn; const serverThenClientFnAbstracted = abstractedClientFn; -const clientThenServerFnAbstracted = abstractedClientFn; \ No newline at end of file +const clientThenServerFnAbstracted = abstractedClientFn; +const withTypeRestriction = () => 'client'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx index 1656889e535..2d683c57633 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx @@ -13,4 +13,5 @@ function abstractedClientFn() { } const clientOnlyFnAbstracted = () => {}; const serverThenClientFnAbstracted = abstractedServerFn; -const clientThenServerFnAbstracted = abstractedServerFn; \ No newline at end of file +const clientThenServerFnAbstracted = abstractedServerFn; +const withTypeRestriction = () => 'server'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx index e3102bcb715..45805800101 100644 --- a/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx +++ b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx @@ -33,3 +33,8 @@ const serverThenClientFnAbstracted = createIsomorphicFn() const clientThenServerFnAbstracted = createIsomorphicFn() .client(abstractedClientFn) .server(abstractedServerFn) + +const withTypeRestriction = createIsomorphicFn() + .$withType<() => string>() + .server(() => 'server') + .client(() => 'client')