Repository: software-mansion/TypeGPU
Pull request #1480 (2025-07-11 09:13:49)

feat: Struct default constructor
Structs can now be called with no arguments in order to get a default value of the struct (as long as there are no nested arrays inside).

This is a prerequisite for #1254 (`d.arrayOf(myStruct, 4)()`)

github-actions[bot]: **pkg.pr.new**

*packages*
Ready to be installed by your favorite package manager ⬇️

```
https://pkg.pr.new/software-mansion/TypeGPU/typegpu@85e26bd2954587ff8ca82646d9f9afbabbe8f7a6
```
```
https://pkg.pr.new/software-mansion/TypeGPU/@typegpu/noise@85e26bd2954587ff8ca82646d9f9afbabbe8f7a6
```
```
https://pkg.pr.new/software-mansion/TypeGPU/unplugin-typegpu@85e26bd2954587ff8ca82646d9f9afbabbe8f7a6
```

*benchmark*
[view benchmark](https://docs.swmansion.com/TypeGPU/benchmark?p=npm-latest_pr-1480)

*commit*
[view commit](https://github.com/software-mansion/TypeGPU/commit/undefined)

Patch:
diff --git a/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx b/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx
index 59ed5a83..2675f116 100644
--- a/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx
+++ b/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx
@@ -49,7 +49,7 @@ const c = vec4f(b.xz, a.xx);  // 0, 2, 1, 1
 
 ## Constructor conventions
 
-Scalar, vector and matrix schemas can be called with no arguments to get a default value of the type represented by the schema.
+Scalar, vector, matrix and struct schemas can be called with no arguments to get a default value of the type represented by the schema. Arrays are a WIP.
 
 ```ts
 import { f32, bool, vec3f } from 'typegpu/data';
diff --git a/packages/typegpu/src/core/function/tgpuFn.ts b/packages/typegpu/src/core/function/tgpuFn.ts
index befb0a16..3f519fbe 100644
--- a/packages/typegpu/src/core/function/tgpuFn.ts
+++ b/packages/typegpu/src/core/function/tgpuFn.ts
@@ -1,5 +1,5 @@
 import { type AnyData, snip, UnknownData } from '../../data/dataTypes.ts';
-import { schemaCallWrapper } from '../../data/utils.ts';
+import { schemaCloneWrapper } from '../../data/utils.ts';
 import { Void } from '../../data/wgslTypes.ts';
 import { createDualImpl } from '../../shared/generators.ts';
 import type { TgpuNamable } from '../../shared/meta.ts';
@@ -233,7 +233,7 @@ function createFn<ImplSchema extends AnyFn>(
       }
 
       const castAndCopiedArgs = args.map((arg, index) =>
-        schemaCallWrapper(shell.argTypes[index], arg)
+        schemaCloneWrapper(shell.argTypes[index], arg)
       ) as InferArgs<Parameters<ImplSchema>>;
 
       return implementation(...castAndCopiedArgs);
diff --git a/packages/typegpu/src/data/struct.ts b/packages/typegpu/src/data/struct.ts
index 00d11ab7..bcf5373f 100644
--- a/packages/typegpu/src/data/struct.ts
+++ b/packages/typegpu/src/data/struct.ts
@@ -1,6 +1,6 @@
 import { getName, setName } from '../shared/meta.ts';
 import { $internal } from '../shared/symbols.ts';
-import { schemaCallWrapper } from './utils.ts';
+import { schemaCloneWrapper, schemaDefaultWrapper } from './utils.ts';
 import type { AnyWgslData, WgslStruct } from './wgslTypes.ts';
 
 // ----------
@@ -23,11 +23,16 @@ export function struct<TProps extends Record<string, AnyWgslData>>(
 ): WgslStruct<TProps> {
   // in the schema call, create and return a deep copy
   // by wrapping all the values in corresponding schema calls
-  const structSchema = <T extends TProps>(instanceProps: T) =>
+  const structSchema = <T extends TProps>(instanceProps?: T) =>
     Object.fromEntries(
       Object.entries(props).map((
         [key, schema],
-      ) => [key, schemaCallWrapper(schema, instanceProps[key])]),
+      ) => [
+        key,
+        instanceProps
+          ? schemaCloneWrapper(schema, instanceProps[key])
+          : schemaDefaultWrapper(schema),
+      ]),
     );
   Object.setPrototypeOf(structSchema, WgslStructImpl);
   structSchema.propTypes = props;
diff --git a/packages/typegpu/src/data/utils.ts b/packages/typegpu/src/data/utils.ts
index aaae0f43..4a50780a 100644
--- a/packages/typegpu/src/data/utils.ts
+++ b/packages/typegpu/src/data/utils.ts
@@ -1,16 +1,31 @@
 /**
  * A wrapper for `schema(item)` call.
- * Logs a warning if the schema is not callable.
+ * Throws an error if the schema is not callable.
  */
-export function schemaCallWrapper<T>(schema: unknown, item: T): T {
-  let result = item;
+export function schemaCloneWrapper<T>(schema: unknown, item: T): T {
   try {
-    result = (
-      schema as unknown as ((item: typeof result) => typeof result)
-    )(item);
+    return (schema as unknown as ((item: T) => T))(item);
   } catch {
     const maybeType = (schema as { type: string })?.type;
-    console.warn(`Schema of type ${maybeType ?? '<unknown>'} is not callable.`);
+    throw new Error(
+      `Schema of type ${
+        maybeType ?? '<unknown>'
+      } is not callable or was called with invalid arguments.`,
+    );
+  }
+}
+
+/**
+ * A wrapper for `schema()` call.
+ * Throws an error if the schema is not callable.
+ */
+export function schemaDefaultWrapper<T>(schema: unknown): T {
+  try {
+    return (schema as unknown as (() => T))();
+  } catch {
+    const maybeType = (schema as { type: string })?.type;
+    throw new Error(
+      `Schema of type ${maybeType ?? '<unknown>'} is not callable.`,
+    );
   }
-  return result;
 }
diff --git a/packages/typegpu/src/data/wgslTypes.ts b/packages/typegpu/src/data/wgslTypes.ts
index 96c6e2b8..e4f3045d 100644
--- a/packages/typegpu/src/data/wgslTypes.ts
+++ b/packages/typegpu/src/data/wgslTypes.ts
@@ -1166,6 +1166,7 @@ export interface WgslStruct<
   TProps extends Record<string, BaseData> = Record<string, BaseData>,
 > extends TgpuNamable {
   (props: Prettify<InferRecord<TProps>>): Prettify<InferRecord<TProps>>;
+  (): Prettify<InferRecord<TProps>>;
   readonly [$internal]: true;
   readonly type: 'struct';
   readonly propTypes: TProps;
diff --git a/packages/typegpu/src/tgsl/wgslGenerator.ts b/packages/typegpu/src/tgsl/wgslGenerator.ts
index fc240af4..64fd8259 100644
--- a/packages/typegpu/src/tgsl/wgslGenerator.ts
+++ b/packages/typegpu/src/tgsl/wgslGenerator.ts
@@ -313,23 +313,28 @@ export function generateExpression(
     }
 
     if (wgsl.isWgslStruct(id.value)) {
-      // There are two ways a struct can be called that we support:
+      const resolvedId = ctx.resolve(id.value);
+      // There are three ways a struct can be called that we support:
+      // - with no arguments `Struct()`,
       // - with an objectExpr `Struct({ x: 1, y: 2 })`,
       // - with another struct `Struct(otherStruct)`.
-      // In the second case, we assume the `otherStruct` is defined on TGSL side
+      // In the last case, we assume the `otherStruct` is defined on TGSL side
       // and we just strip the constructor to let the assignment operator clone it.
-      const resolvedId = args.length === 1 &&
-          Array.isArray(args[0]) &&
-          args[0].length > 0 &&
-          args[0][0] === NODE.objectExpr
-        ? ctx.resolve(id.value)
-        : '';
+      if (args.length === 0) {
+        return snip(`${resolvedId}()`, id.value);
+      }
 
-      return snip(
-        `${resolvedId}(${argValues.join(', ')})`,
-        // Unintuitive, but the type of the return value is the struct itself
-        id.value,
-      );
+      if (
+        args.length === 1 &&
+        Array.isArray(args[0]) &&
+        args[0].length > 0 &&
+        args[0][0] === NODE.objectExpr
+      ) {
+        return snip(`${resolvedId}(${argValues.join(', ')})`, id.value);
+      }
+
+      // The type of the return value is the struct itself
+      return snip(`(${argValues.join(', ')})`, id.value);
     }
 
     if (!isMarkedInternal(id.value)) {


Test patch:
diff --git a/packages/typegpu/tests/struct.test.ts b/packages/typegpu/tests/struct.test.ts
index 7f473802..36ed15ad 100644
--- a/packages/typegpu/tests/struct.test.ts
+++ b/packages/typegpu/tests/struct.test.ts
@@ -18,7 +18,9 @@ import {
   vec3h,
   vec3u,
 } from '../src/data/index.ts';
+import tgpu from '../src/index.ts';
 import type { Infer } from '../src/shared/repr.ts';
+import { parse, parseResolved } from './utils/parseResolved.ts';
 
 describe('struct', () => {
   it('aligns struct properties when measuring', () => {
@@ -254,7 +256,7 @@ describe('struct', () => {
     });
 
     // @ts-expect-error
-    TestStruct({ x: 1, z: 2 });
+    (() => TestStruct({ x: 1, z: 2 }));
   });
 
   it('can be called to create a deep copy of other struct', () => {
@@ -277,4 +279,49 @@ describe('struct', () => {
 
     expect(clone).toStrictEqual({ prop1: vec2f(1, 2), prop2: 21 });
   });
+
+  it('can be called to create a default value', () => {
+    const schema = struct({ nested: struct({ prop1: vec2f, prop2: u32 }) });
+
+    const defaultStruct = schema();
+
+    expect(defaultStruct).toStrictEqual({
+      nested: { prop1: vec2f(), prop2: u32() },
+    });
+  });
+
+  // TODO: uncomment this once this starts working
+  // it('can be called to create a default value with nested array', () => {
+  //   const schema = struct({ arr: arrayOf(u32, 1) });
+
+  //   const defaultStruct = schema();
+
+  //   expect(defaultStruct).toStrictEqual({ arr: [0] });
+  // });
+
+  it('resolves to correct wgsl', () => {
+    const Nested = struct({ prop1: vec2f, prop2: u32 });
+    const Outer = struct({
+      nested: Nested,
+    });
+
+    const testFunction = tgpu.fn([])(() => {
+      const defaultValue = Outer();
+    });
+
+    expect(parseResolved({ testFunction })).toBe(parse(`
+        struct Nested {
+          prop1: vec2f,
+          prop2: u32,
+        }
+
+        struct Outer {
+          nested: Nested,
+        }
+
+        fn testFunction() {
+          var defaultValue = Outer();
+        }
+      `));
+  });
 });