DrillLab
第 03 / 17 节LESSON 03 / 17约 13 分钟~13 min

非空、列表,和那个没有 price 的 inputNon-null, lists, and the input that has no price

schema 里两处细节,直接决定四个 TODO 里三个的对错。Two details in the schema decide whether three of the four TODOs are right.

3 个练习3 exercisesFederation · 第 1 部分Federation · Part 1
这一页有什么On this page5
学完这节你会After this lesson you can
  • 读懂 ! 和 [] 的四种组合各是什么意思Read the four combinations of ! and [] and say what each means
  • 解释为什么 [Order!]! 的 resolver 必须写 ?? []Explain why a resolver for [Order!]! must end with ?? []
  • 看出 OrderItemInput 少了 price 会导致什么See what goes wrong because OrderItemInput has no price
  • 知道非空字段返回 null 时错误会怎样向上冒泡Know how the error moves upward when a non-null field returns null
这在考试里考什么What the exam does with this

这一节讲的两处细节,是这门考试最典型的「不读 schema 就必错」的地方。审计时实测确认:createOrder 不补 price,测试直接失败。The two details in this lesson are the clearest case of what you get wrong by not reading the schema. Measured during the audit: if createOrder does not fill in price, the test fails.

这节课要看的真实文件Real files this lesson looks at2 项 · 2 个可以展开看原文2 items · 2 can be opened
graphql-federation-practice/node-subgraph/src/schema.graphql非空标记与 input 定义The non-null markers and the input definitions
GraphQL SDLschema.graphql源项目From source
1extend schema
2 @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable", "@external"])
3
4type User @key(fields: "id") {
5 id: ID! @external
6 orders: [Order!]!
7}
8
9type Order {
10 id: ID!
11 userId: ID!
12 status: OrderStatus!
13 totalAmount: Float!
14 items: [OrderItem!]!
15 createdAt: String!
16 shippingInfo: ShippingInfo
17}
18
19type OrderItem {
20 productId: ID!
21 quantity: Int!
22 price: Float!
23}
24
25type ShippingInfo {
26 status: String!
27 estimatedDelivery: String
28 trackingNumber: String
29}
30
31enum OrderStatus {
32 PENDING
33 PROCESSING
34 SHIPPED
35 DELIVERED
36 CANCELLED
37}
38
39type Query {
40 order(id: ID!): Order
41 orders(userId: ID!): [Order!]!
42}
43
44type Mutation {
45 createOrder(userId: ID!, items: [OrderItemInput!]!): Order!
46}
47
48input OrderItemInput {
49 productId: ID!
50 quantity: Int!
51}
Source: graphql-federation-practice/node-subgraph/src/schema.graphql
graphql-federation-practice/node-subgraph/src/dataSources/orderDataSource.jscreateOrder 里那行乘法暴露了 price 的必要性The multiplication inside createOrder shows why price is needed
JavaScriptorderDataSource.js源项目From source
1// Mock data sources simulating downstream microservices
2
3class OrderDataSource {
4 constructor() {
5 this.orders = [
6 {
7 id: 'order-456',
8 userId: '123',
9 status: 'SHIPPED',
10 totalAmount: 299.99,
11 items: [{ productId: 'prod-789', quantity: 2, price: 149.99 }],
12 createdAt: '2026-01-01T10:30:00Z'
13 },
14 {
15 id: 'order-457',
16 userId: '123',
17 status: 'DELIVERED',
18 totalAmount: 89.99,
19 items: [{ productId: 'prod-101', quantity: 1, price: 89.99 }],
20 createdAt: '2025-12-15T14:20:00Z'
21 },
22 {
23 id: 'order-458',
24 userId: '456',
25 status: 'PENDING',
26 totalAmount: 199.99,
27 items: [{ productId: 'prod-202', quantity: 1, price: 199.99 }],
28 createdAt: '2026-01-05T09:15:00Z'
29 }
30 ];
31 }
32
33 async getOrder(id) {
34 await new Promise(resolve => setTimeout(resolve, 10));
35 return this.orders.find(order => order.id === id);
36 }
37
38 async getOrdersByUserId(userId) {
39 await new Promise(resolve => setTimeout(resolve, 10));
40 return this.orders.filter(order => order.userId === userId);
41 }
42
43 async createOrder(userId, items) {
44 await new Promise(resolve => setTimeout(resolve, 10));
45
46 const totalAmount = items.reduce((sum, item) => {
47 return sum + item.price * item.quantity;
48 }, 0);
49
50 const newOrder = {
51 id: `order-${Date.now()}`,
52 userId,
53 status: 'PENDING',
54 totalAmount,
55 items,
56 createdAt: new Date().toISOString()
57 };
58
59 this.orders.push(newOrder);
60 return newOrder;
61 }
62}
63
64class InventoryDataSource {
65 async getInventoryStatus(productIds) {
66 await new Promise(resolve => setTimeout(resolve, 10));
67 return productIds.map(id => ({ productId: id, inStock: true, quantity: 100 }));
68 }
69
70 async getProductPrice(productId) {
71 await new Promise(resolve => setTimeout(resolve, 5));
72 const prices = {
73 'prod-789': 149.99,
74 'prod-101': 89.99,
75 'prod-202': 199.99
76 };
77 return prices[productId] || 99.99;
78 }
79}
80
81class ShippingDataSource {
82 async getShippingInfo(orderId) {
83 await new Promise(resolve => setTimeout(resolve, 10));
84
85 const shippingData = {
86 'order-456': {
87 status: 'IN_TRANSIT',
88 estimatedDelivery: '2026-01-10',
89 trackingNumber: 'TRACK123456'
90 },
91 'order-457': {
92 status: 'DELIVERED',
93 estimatedDelivery: '2025-12-20',
94 trackingNumber: 'TRACK123457'
95 }
96 };
97
98 return shippingData[orderId] || null;
99 }
100}
101
102export { OrderDataSource, InventoryDataSource, ShippingDataSource };
Source: graphql-federation-practice/node-subgraph/src/dataSources/orderDataSource.js
§01

! 和 [] 的四种组合The four combinations of ! and []

默认可空,加 ! 才不可空。列表和元素各有自己的可空性。Fields are nullable by default; ! makes them non-null. The list and its elements each have their own nullability.

GraphQL 里所有类型默认可空! 是「保证不为 null」。 列表的方括号和元素各能带一个 !, 所以有四种组合:

写法列表本身能是 null 吗元素能是 null 吗合法的值举例
[Order]null[][a, null]
[Order!]不能null[][a, b]
[Order]!不能[][a, null]
[Order!]!不能不能[][a, b]

这份 schema 里有两处用了最严格的[Order!]!User.ordersQuery.orders。 两个都是你要实现的 TODO。

实践结论:这两个 resolver 绝对不能返回nullundefined「没有订单」的正确表达是空数组 [], 不是 null。所以真实答案里都写了return orders ?? []

Every type in GraphQL is nullable by default. ! means “guaranteed not to be null”. The brackets of a list and the elements inside it can each carry their own !, which gives four combinations:

Written asCan the list be nullCan an element be nullLegal values
[Order]yesyesnull, [], [a, null]
[Order!]yesnonull, [], [a, b]
[Order]!noyes[], [a, null]
[Order!]!nono[], [a, b]

This schema uses the strictest form, [Order!]!, in two places: User.orders and Query.orders. Both of them are TODOs you have to implement.

Practical conclusion: those two resolvers must never return null or undefined. The right way to say “no orders” is an empty array [], not null. Which is why both real answers write return orders ?? [].

GraphQL SDL四个 TODO 对应的返回类型The return types behind the four TODOs源项目From source
1type User @key(fields: "id") {
2 id: ID! @external
3 orders: [Order!]! # ← 双重非空
4}
5
6type Query {
7 order(id: ID!): Order # ← 可空:找不到返回 null 是合法的
8 orders(userId: ID!): [Order!]! # ← 双重非空
9}
10
11type Order {
12 shippingInfo: ShippingInfo # ← 可空:没有物流信息返回 null 是合法的
13}
1type User @key(fields: "id") {
2 id: ID! @external
3 orders: [Order!]! # ← non-null twice
4}
5
6type Query {
7 order(id: ID!): Order # ← nullable: null when not found is legal
8 orders(userId: ID!): [Order!]! # ← non-null twice
9}
10
11type Order {
12 shippingInfo: ShippingInfo # ← nullable: null when there is no shipping is legal
13}
Source: graphql-federation-practice/node-subgraph/src/schema.graphql
对照着看:两个列表字段必须 ?? [] 兜底;Query.order 和 Order.shippingInfo 可以返回 null。这四行决定了你四个 TODO 各自的兜底策略。Read them side by side: the two list fields must fall back with ?? [], while Query.order and Order.shippingInfo are allowed to return null. These four lines decide the fallback for each of your four TODOs.
§02

非空字段返回 null 会怎样:错误向上冒泡What happens when a non-null field returns null: the error moves upward

不是「那个字段变成 null」,是整块数据被丢掉。The field does not just become null. The whole block of data is dropped.

如果 Query.orders 返回了 null, GraphQL 执行器不会容忍 —— 它会:

  1. errors 数组里加一条Cannot return null for non-nullable field Query.orders
  2. 把这个字段的值设为 null,然后往上冒泡 —— 如果父字段也是非空的,父字段也变 null,一直往上, 直到遇到一个可空的祖先,或者到根节点让整个data 变成 null

所以一个 resolver 忘了兜底,可能导致整个响应的 data 变成 null —— 客户端拿不到任何数据,即使其他字段都好着。 这就是为什么 schema 设计里「该可空的地方就标可空」很重要, 也是为什么这两个列表字段必须 ?? []

反过来,Order.shippingInfo可空的, 所以「order-999 没有物流信息」这种情况返回 null完全正常,不会报错。数据源那边正是这么设计的 ——getShippingInfo 只有 order-456/457 有数据, 其余返回 null。测试也直接断言了这一点。

If Query.orders returns null, the GraphQL executor will not put up with it. It:

  1. adds Cannot return null for non-nullable field Query.orders to the errors array.
  2. sets that field to null and bubbles upward — if the parent field is also non-nullable, the parent becomes null too, and so on up the tree until it reaches a nullable ancestor, or hits the root and turns the whole data into null.

So one resolver forgetting its fallback can turn the data of the whole response into null — the client gets nothing back, even though every other field was fine. That is why “mark it nullable where it should be nullable” matters in schema design, and why those two list fields need ?? [].

The other direction: Order.shippingInfo is nullable, so returning null for “order-999 has no shipping info” is perfectly normal and raises no error. The data source is built that way on purpose — getShippingInfo only has data for order-456 and order-457 and returns null for everything else. A test asserts exactly this.

§03

OrderItemInput 少了 price —— 这是个陷阱OrderItemInput has no price — this is a trap

两个文件放在一起看,才能发现问题。You only see the problem when you read the two files side by side.

先看 schema 里的 input:只有 productIdquantity。 客户端调 createOrder不传 price(合理 —— 价格不能让客户端说)。

再看数据源的 createOrder:它内部要算 sum + item.price * item.quantity

问题来了:如果 resolver 把客户端传来的 items 原样交给数据源,那 item.priceundefinedundefined * 2 得到 NaNtotalAmount 变成 NaN。 而 totalAmount: Float! 收到 NaN 会序列化失败。

解法:resolver 必须先去InventoryDataSource.getProductPrice(productId)查每个商品的价格,把 items 补全之后再交给数据源。

这就是 InventoryDataSource 存在的原因 —— 它不是干扰项。(getInventoryStatus 才是干扰项, 没有任何地方需要它。)

测试怎么抓这个的?expect(order.items[0].price).toBeDefined()expect(order.totalAmount).toBeGreaterThan(0)。 审计时实测:不补 price,这个测试失败。

First, the input in the schema: only productId and quantity. A client calling createOrder never sends price — which is reasonable, the client does not get to name the price.

Now the createOrder in the data source: internally it computes sum + item.price * item.quantity.

Here is the problem: if the resolver hands the client’s items straight to the data source, then item.price is undefined, undefined * 2 gives NaN, and totalAmount becomes NaN. And totalAmount: Float! cannot serialise a NaN.

The fix: the resolver has to look up each product’s price with InventoryDataSource.getProductPrice(productId) and complete the items before handing them to the data source.

That is why InventoryDataSource exists — it is not a distractor. (getInventoryStatus is the distractor; nothing anywhere needs it.)

How does the test catch this? expect(order.items[0].price).toBeDefined() and expect(order.totalAmount).toBeGreaterThan(0). Measured during the audit: skip the price lookup and this test fails.

GraphQL SDL源项目From source
1input OrderItemInput {
2 productId: ID!
3 quantity: Int!
4}
5# ↑ 没有 price
1input OrderItemInput {
2 productId: ID!
3 quantity: Int!
4}
5# ↑ no price
Source: graphql-federation-practice/node-subgraph/src/schema.graphql
JavaScriptOrderDataSource.createOrder源项目From source
1async createOrder(userId, items) {
2 await new Promise(resolve => setTimeout(resolve, 10));
3
4 const totalAmount = items.reduce((sum, item) => {
5 return sum + item.price * item.quantity; // ← 它需要 price!
6 }, 0);
7
8 const newOrder = {
9 id: `order-${Date.now()}`,
10 userId,
11 status: 'PENDING',
12 totalAmount,
13 items,
14 createdAt: new Date().toISOString()
15 };
16
17 this.orders.push(newOrder);
18 return newOrder;
19}
1async createOrder(userId, items) {
2 await new Promise(resolve => setTimeout(resolve, 10));
3
4 const totalAmount = items.reduce((sum, item) => {
5 return sum + item.price * item.quantity; // ← it needs price!
6 }, 0);
7
8 const newOrder = {
9 id: `order-${Date.now()}`,
10 userId,
11 status: 'PENDING',
12 totalAmount,
13 items,
14 createdAt: new Date().toISOString()
15 };
16
17 this.orders.push(newOrder);
18 return newOrder;
19}
Source: graphql-federation-practice/node-subgraph/src/dataSources/orderDataSource.js
JavaScriptInventoryDataSource.getProductPrice —— 缺的那块拼图InventoryDataSource.getProductPrice — the missing piece源项目From source
1async getProductPrice(productId) {
2 await new Promise(resolve => setTimeout(resolve, 5));
3 const prices = {
4 'prod-789': 149.99,
5 'prod-101': 89.99,
6 'prod-202': 199.99
7 };
8 return prices[productId] || 99.99;
9}
Source: graphql-federation-practice/node-subgraph/src/dataSources/orderDataSource.js
注意它有兜底:未知商品返回 99.99。所以不会出现 undefined。Note it has a fallback: an unknown product returns 99.99. So you never get undefined.
练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L1认出来Spot it这个 resolver 找不到数据时该返回什么What should this resolver return when it finds nothing

schema 写的是 orders(userId: ID!): [Order!]!。 user 999 没有任何订单。resolver 该返回什么?

The schema says orders(userId: ID!): [Order!]!. User 999 has no orders at all. What should the resolver return?

先选一个选项Pick an option first
L1认出来Spot itcreateOrder 为什么必须查价格Why createOrder has to look up the price

客户端调 createOrder(userId: "789", items: [{ productId: "prod-789", quantity: 2 }])。 如果 resolver 把 items 原样交给orderDataSource.createOrder,会怎样?

The client calls createOrder(userId: "789", items: [{ productId: "prod-789", quantity: 2 }]). What happens if the resolver hands items straight to orderDataSource.createOrder?

先选一个选项Pick an option first
L2填空Fill the blanks给四个 TODO 各自选对兜底策略Pick the right fallback for each of the four TODOs

照 schema 的非空标记,给每个 resolver 填上正确的返回表达式。 想清楚「这个字段能不能是 null」。

Go by the non-null markers in the schema and write the right return expression for each resolver. Decide first whether the field is allowed to be null.

JSsrc/resolvers/orderResolvers.js3 个空3 blanks
1// schema: orders: [Order!]!
2async orders(user, _, { dataSources }) {
3 const orders = await dataSources.orderDataSource.getOrdersByUserId(user.id);
4 return orders [];
5}
6
7// schema: shippingInfo: ShippingInfo (可空)
8async shippingInfo(parent, _, { loaders }) {
9 const info = await loaders.shippingInfoLoader.load(parent.id);
10 return info ?? ;
11}
12
13// schema: order(id: ID!): Order (可空,但题目要求找不到时抛结构化错误)
14async order(_, { id }, { loaders, correlationId }) {
15 const order = await loaders.orderLoader.load(id);
16 if () {
17 throw new GraphQLError(`Order not found: ${id}`, {
18 extensions: { code: ErrorCodes.ORDER_NOT_FOUND, correlationId }
19 });
20 }
21 return order;
22}
把 3 个空都填上才能检查(还差 3 个)Fill all 3 blanks to check (3 to go)
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

字段类型是 [T!]!The field type is [T!]!
resolver 必须 ?? [] 兜底,绝不返回 nullThe resolver must fall back with ?? [] and never return null
字段类型没有 !The field type has no !
返回 null 是合法的,但要显式写 ?? nullReturning null is allowed, but write ?? null explicitly
input 里少了某个字段但下游需要它An input is missing a field that later code needs
resolver 负责补齐,去对应数据源查The resolver fills it in by asking the right data source
整个 data 变成了 nullThe whole data object came back null
某个非空字段返回了 null,往上冒泡了Some non-null field returned null and the error moved upward
这节的要点What to take away
  1. GraphQL 默认可空,加上 ! 才不可空;列表和元素各有自己的可空性。GraphQL fields are nullable by default; ! makes them non-null. The list and its elements each have their own nullability.
  2. [Order!]! 的 resolver 必须 ?? [] —— 「没有」的正确表达是空数组。A resolver for [Order!]! must use ?? []. Here an empty array is how you say there is nothing.
  3. 非空字段返回 null 会向上冒泡,可能让整个 data 变成 null。When a non-null field returns null the error moves upward and can turn the whole data object into null.
  4. shippingInfo 可空,测试断言 toBeNull —— 所以要显式 ?? null,别让 undefined 漏出去。shippingInfo is nullable and the test asserts toBeNull, so write ?? null explicitly and do not let undefined through.
  5. OrderItemInput 没有 price,而数据源要用它算总价 → resolver 必须先查 getProductPrice。OrderItemInput has no price, but the data source needs it to compute the total, so the resolver must call getProductPrice first.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises3 个,就在这一页上面 —— 别攒着最后一起做3 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson为什么会有 FederationWhy Federation exists
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: resolver 的四个参数The four arguments of a resolver